From 6ab4f0e557f2442794cedf0bbb78e4e28d4e22d0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:29:04 +0200 Subject: [PATCH 01/32] docs: a phased plan for chapter uploads, group-signed Mirrors mantra.press's multi-file chapter upload under this app's rule that the group, not the uploader, authors the work. The web app's shape -- N files in, N chapters out, one transaction, one redirect -- is the one shape this app cannot have. A signing session is one quorum, all-or-nothing, 64 items and about 64 KiB on the wire, so twelve chapters in one batch does not fit, should not fit, and would ask every member to read twelve chapters in a single approval gate. One upload is N sessions, one per chapter, and that cost belongs in front of whoever is picking files. Reading both trees turned up four things the single-chapter path gets away with at N = 1 and an upload does not: the index is read from rows and no proposed chapter is a row yet, so twelve proposals all sign the same index; nothing checks the proposal's size, which puts a silent ceiling near 28 KiB of markdown; there is no file picker in the app at all; and translation scaffolding opens a session per chapter where one batch would do. The splitter is measured rather than assumed. Run side by side, markdown-it's block tokens and this app's blank-line rule disagree on ten cases in eleven, and on the text as well as the boundaries -- markdown-it strips the block marker, so `# Chapter one` is `"Chapter one"`. Phases 7 and 8 adopt it, with three deliberate departures (no chunk for code blocks, one chunk per table, none for a link reference definition) and a producer, which the app has never had. Co-Authored-By: Claude Opus 5 --- docs/README.md | 10 + docs/chapter-uploads.md | 916 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 926 insertions(+) create mode 100644 docs/chapter-uploads.md diff --git a/docs/README.md b/docs/README.md index 933e8da5..e117b41b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ silent, or a decision that looked arbitrary and was not. | [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 | | [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 | | [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 | | [curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md) | the second pull: the fork's several-profiles line, ten commits and its first schema migration — which of it is a fix Mantra has today, who allocates a schema version number when two trees share one history, and the one conflict, which was Mantra's own | @@ -72,3 +73,12 @@ smallest of the plans: it changes the entrance to the direct-message flow and nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and `ChatRoomMessagingViewModel.initiateNewChat` open, its four decisions before its four phases, and its table of where the build chose differently first. +The chapter uploads note is a phased plan that has not been built, and is the +only one that starts from another codebase: it mirrors the mantra.press web app's +multi-file upload under this app's rule that the group, not the uploader, is the +author. Read the batch-signing note first — its three limits are the whole shape +of this one — and read its four-defects section even if the feature is never +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. diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md new file mode 100644 index 00000000..0ad6c375 --- /dev/null +++ b/docs/chapter-uploads.md @@ -0,0 +1,916 @@ +# Chapter uploads + +Picking files off a device and having a group sign each one into an artifact as a +`MantraChapter` with its chunks — the way +[mantra.press](https://code.sigidli.com/mantra/mantra.press) does it over one +HTTP POST, except that here nobody is authoring anything on their own. + +**Not built.** This is a plan. The constraint that shapes every phase is stated +first, because the web app's shape — N files in, N chapters out, one +transaction, one redirect — is the one shape this app cannot have, and every +phase below is a consequence of that rather than a feature. + +--- + +## What mantra.press does + +One route, `POST /v/:id/chapters/upload`, in +`server/router/versions/index.js`. A multipart form with `multiple` on the file +input (`server/views/chapters-upload.pug`), and for each file: + +```js +const fileText = file.data.toString() +const chunks = md.parse(fileText).filter(t => t.content).map(t => t.content) +db.Chapter.create({ + creatorId: userId, + name: file.name, + originalText: fileText, + wordCount: chunks.reduce(wordCounter, 0), + characterCount: chunks.reduce(characterCounter, 0), + index: chapterIndex, + artifactVersionId: artifactVersion.id, + chunks: chunks.map((chunk, index) => ({ ... })), +}, { include: [{ association: db.Chapter.Chunks }] }) +``` + +Six decisions in there worth naming, because this plan either keeps or +deliberately drops each one: + +| mantra.press | here | +|---|---| +| the chapter's name is the **filename** | kept as the default, editable before proposing (§ Phase 4) | +| the index is `chapters.length + 1`, re-read from the version | allocated once for the whole upload, counting proposals as well as rows (§ Phase 3) | +| chunks are **markdown-it block tokens**, with the block marker stripped | adopted, with three deliberate departures (§ Phase 7, built in § Phase 8) | +| the uploader is the author (`creatorId`) | the **group** is the author, once a quorum signs | +| all files succeed or the request 500s | one session per chapter, each failing on its own (§ The constraint) | +| `file.data.toString()`, whatever the bytes are | UTF-8 decoded strictly, non-text refused (§ Phase 1) | + +The web app has two small bugs in that loop that are worth knowing about so they +are not faithfully reproduced. `request.files.chapters.length > 0` is `undefined` +for a single file, so one file always takes the `else` branch — harmless, but it +means the multi-file path has had less use than it looks. And +`Number(chapterIndex) + Number(index)` starts from a `chapterIndex` that is +already `length + 1`, so a second file lands at `length + 2` and the first +upload into an empty version starts at index 1 while the add-chapter form starts +at `length`. Indices here are 0-based (`ChapterEvent.build(index = …)`, written +by `AddChapterViewModel` as `getChaptersForArtifactVersion(...).size`), and this +plan keeps them 0-based. + +## What is already here + +Nearly all of it, for one chapter at a time. This plan adds no event kind, no +tag and no schema version to the chapter path. + +- [`ChapterEvent`](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt) + (30302) carries the whole markdown in an `original_text` tag; + [`ChunkEvent`](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt) + (30303) carries one paragraph, and `ChunkEvent.splitOf(chapter)` cuts the + chunks from a chapter *after* it is authored, which is the only place the + chapter id exists to be named. +- [`AddChapterViewModel.addChapter`](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt) + proposes the chapter and its chunks as one batch via + `proposeSigningBatch(lead = …, dependents = ChunkEvent::splitOf)`, then opens + a second session scaffolding the chapter into every translation of the + version. +- `ChatMessage.applySignedEvent` has both branches (`ChapterEvent.KIND`, + `ChunkEvent.KIND`), writes `groupSignedEventId` provenance, and + `GroupSignedEvent` keeps the event as the group signed it. +- [`ChapterBatchProposalJvmTest`](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt) + and `SignedChapterTest` already cover the single-chapter batch end to end. + +So the work is **not** "make a chapter group-signed". It is: get bytes off three +platforms, turn a pile of files into a reviewable list of proposals, and deal +with the four things a single hand-typed chapter gets away with and a twelve-file +upload does not. + +--- + +## The constraint + +A signing session is **one quorum, all-or-nothing, at most 64 items, and about +64 KiB on the wire**. + +Three numbers, from three places, and the plan is shaped by all three: + +- `FrostSigningManager.MAX_BATCH_SIZE = 64`, checked in `proposeSigningBatch` + *and* independently in `acceptProposal` — see + [frost-batch-signing.md](./frost-batch-signing.md). +- *All-or-nothing*: "a batch is only as available as its worst item. If any + event cannot be aggregated the session fails and none of them are applied." +- *Bytes*: the proposal carries `k` whole events, which is the term that binds. + `ChronicleEvent.MAX_PAGE_BYTES = 64 * 1024` is this codebase's measured proxy + for what a kind:445 clears "with room for the MLS framing and the NIP-44 + expansion that sit outside it". + +### The shape that follows: one session per chapter + +The web app's single transaction has no analogue. Twelve chapters in one batch +is wrong three times over: + +1. **It does not fit.** A chapter takes one item and its paragraphs take the + rest (`MAX_CHUNKS_PER_CHAPTER = 63`). Two chapters of thirty paragraphs each + already overflow. +2. **It should not fit.** A batch is only as available as its worst item, so one + chapter that fails to reach quorum would take the other eleven with it. A + book is precisely a set of things that do *not* need to arrive together. +3. **It is the wrong prompt.** Every member would be asked to read twelve + chapters' full text in one approval gate, and the gate's own rule is that it + "must show every event, not a count". + +So: **one upload is N sessions, one per chapter.** That is the honest cost and +it has to be in front of whoever is picking files, not discovered afterwards — +N proposals, each needing a quorum, arriving in the room at once. + +What it buys: a chapter that fails is re-proposable on its own, chapters land +and become readable as each quorum arrives rather than all at the end, and the +approval gate stays one chapter long. + +--- + +## Four things the single-chapter path gets away with + +Each is a real defect today, each is invisible at N = 1, and each is a phase. + +**The index is read from rows, and no chapter is a row until its quorum lands.** +`addChapter` does `getChaptersForArtifactVersion(artifactVersion.id).size` at +propose time. Propose twelve chapters back to back and all twelve read the same +size, so all twelve are signed with the same index. The same collision already +exists between two members adding a chapter at the same moment; an upload just +guarantees it. **Phase 3.** + +**There is no byte check at all.** The paragraph-count cap is enforced and shown +as you type; the size of the proposal is not checked anywhere. A proposal +carries the chapter (whole markdown) *plus* a chunk per paragraph (the same +words again), so it is roughly 2× the text before framing — which puts the +ceiling near 28–30 KiB of markdown, well inside the range of a real book +chapter. Today that is an `IllegalArgumentException`-free failure: the proposal +goes out, the group event is over size, and the session stalls. **Phase 2.** + +**There is no file picker in the app.** Nothing in `composeApp/src` references +`rememberLauncherForActivityResult`, `OpenDocument`, FileKit or any equivalent; +the only platform `expect` declarations are for the database builder, context, +theme and motion. Three targets need it: `androidTarget`, `jvm`, and the two ios +targets gated behind macOS in `composeApp/build.gradle.kts`. **Phase 1.** + +**Translation scaffolding fans out per chapter.** `addChapter` opens a second +session per chapter for the translation chapters. Twelve chapters in a group +with three dialects is twelve chapter sessions plus twelve scaffolding sessions += twenty-four proposals, where the scaffolding is 36 tiny events that fit in one +batch. `TranslationScaffold.chaptersOf` already takes a *list* of chapters. +**Phase 5.** + +--- + +## Phase 1 — a file in, as text + +**Two days, most of it the three actuals. No protocol change, no new event.** + +Ship the picker on its own, before anything knows about multiple chapters, and +prove it on all three platforms. It lands as a **"load from file" button on the +existing `AddChapterScreen`** that fills `originalTextFieldState` and the name +field: one file, straight into the form that already works, so the picker is +exercised and reviewable without the batching argument attached to it. + +### The API + +```kotlin +// commonMain +data class PickedFile(val name: String, val bytes: ByteArray) + +@Composable +expect fun rememberFilePicker(onPicked: (List) -> Unit): FilePicker + +interface FilePicker { fun launch(multiple: Boolean) } +``` + +Returned as bytes rather than a path or a stream. A path is meaningless on iOS +once the picker's security scope closes, and a stream would make the decode +decision below somebody else's problem on a background dispatcher. Chapters are +tens of kilobytes; this is not where to be clever. + +### The three actuals, or one dependency + +**Built with the three actuals, not FileKit.** The plan recommended the +dependency and the implementation went the other way, for three reasons that +only became clear with the tree open: this is the app's only dialog, the three +calls are a dozen lines each, and the project already keeps four platform seams +of exactly this shape (`PlatformContext`, `PlatformDatabaseBuilder`, +`platformReducedMotion`, `platformThemeContrast`) — so the seam was free and the +library's Compose-version coupling was not. The ios target cannot be compiled on +a linux host either way, so a dependency would not have bought verification +where verification is actually missing. + +**The three actuals**, which were a day's work and no mystery: + +| target | call | +|---|---| +| `androidMain` | `rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments())` | +| `jvmMain` | `java.awt.FileDialog` with `isMultipleMode = true` | +| `iosMain` | `UIDocumentPickerViewController(forOpeningContentTypes:)`, `allowsMultipleSelection = true` | + +Android needs no manifest permission for `OpenDocument` — it is a SAF handoff. +iOS needs the document types declared; `.md` has no registered UTI, so +`UTTypePlainText` plus `UTTypeData` and sniff afterwards. + +Three things the build added that the plan had not thought of: + +- **Both android contracts are registered**, `OpenDocument` and + `OpenMultipleDocuments`, and `launch(multiple)` picks. Registering only the + multiple one would offer a six-file picker to a form that keeps one file. +- **Every actual stops reading one byte past the cap**, not at it, so an + over-size file arrives over-size rather than silently truncated — a truncated + chapter decodes cleanly and would be proposed with its ending missing. +- **The dialog title is a parameter**, read from the catalogue by the caller, + because `stringResource` is composable and a `FileDialog` is not. Only desktop + shows it; android's and iOS's pickers title themselves. Taken on all three so + that the one platform showing text to a reader gets it from the catalogue like + every other string in the app. + +**The ios actual is unverified.** The two ios targets are declared only on macos, +so `FilePicker.ios.kt` is not compiled on a linux host and has never run. It is +written to the shape of the eight other `.ios.kt` actuals beside it and says so +in its own KDoc. A stub returning no files would have been worse: it reads as a +working picker that a reader's chapters vanish into. + +### Decoding, which is where the web app is wrong + +`file.data.toString()` on a `.docx` produces a chapter of zip header garbage, +and the group would be asked to sign it. Here: + +```kotlin +// commonMain +object ChapterText { + const val MAX_FILE_BYTES = 1 * 1024 * 1024 + + sealed interface Result { + data class Text(val markdown: String) : Result + data object NotText : Result + data object TooLarge : Result + } + + fun decode(bytes: ByteArray): Result +} +``` + +Four rules, each with a reason: + +- **Strict UTF-8.** `decodeToString(throwOnInvalidSequence = true)` and refuse on + failure. A replacement character signed into an `original_text` tag is a + permanent `U+FFFD` in the group's record. +- **Strip the BOM.** `U+FEFF` at the head of the first paragraph is invisible in + the form and changes the chunk's text, so it changes the chunk's id. +- **Normalise CRLF to LF before hashing.** `Markdown.PARAGRAPH_BREAK` is + `(?:\r?\n){2,}`, so splitting already tolerates CRLF — but the `original_text` + tag would carry the `\r`s, and the same chapter uploaded from Windows and from + Linux would be two different events. Normalise once, at the door. +- **Sniff, do not trust the extension.** A NUL byte in the first 8 KiB means + `NotText`. Cheap, catches every office format and every image. + +`MAX_FILE_BYTES` is a crash guard, not the real limit — the real one is Phase 2 +and is twenty times tighter. + +**Tests** (`commonTest`): a `ChapterTextTest` over valid UTF-8, invalid UTF-8, +BOM, CRLF-vs-LF producing identical text, a NUL-bearing payload, and the size +cap. All pure; no picker involved. 13 of them, plus 9 in a `ChapterNamingTest` +that the plan had folded into Phase 4 and which belongs here — the default name +is derived from the file, so it arrives with the file. + +`ChapterNaming` turned out to deserve its own object rather than a line in the +upload screen. The web app has two answers and uses a different one per route — +`file.name` on the upload, `chunks[0].trim()` on the paste form — and both are +right some of the time. The rule built here is: the filename with its extension +and ordinal prefix removed, falling through to the text's first heading and then +its first line when the filename is only an ordinal. `01.md` is not a chapter +title; `03 - The Wreck.md` is. + +--- + +## Phase 2 — the byte cap, in front of the user + +**Half a day. A fix to the path that exists, and a prerequisite for Phase 4.** + +A chapter whose markdown is over roughly 28 KiB cannot be proposed today and +nothing says so. Make the limit explicit and check it where the paragraph cap is +already checked. + +```kotlin +// ChapterEvent companion, beside build() +/** + * The most markdown a chapter may carry. + * + * A chapter is proposed with a chunk per paragraph, so the proposal carries its + * text roughly twice -- whole in the chapter's `original_text` tag, and again + * split across the chunks -- before any framing. The group event that carries + * the proposal is sized by ChronicleEvent.MAX_PAGE_BYTES; half of it, less room + * for k event envelopes, is what is left for prose. + * + * Measure it against a real proposal rather than trusting this arithmetic, the + * way MAX_PAGE_BYTES itself says to. + */ +const val MAX_ORIGINAL_TEXT_BYTES = 24 * 1024 +``` + +24 KiB rather than 28: the envelopes are not free (an id, a pubkey and a +signature are ~370 bytes before an event says anything, times `k`), and a cap +that is comfortably under the wire limit fails in the form instead of on the +wire. It should be arrived at by measuring an encoded proposal for a 63-chunk +chapter, not by this paragraph. + +**And the number is provisional until Phase 8 lands.** Block splitting produces +more chunks than blank-line splitting, so the same prose carries more envelopes; +whichever of the two phases goes second re-runs the measurement. + +Where it goes: + +- `AddChapterViewModel.addChapter` — beside the + `paragraphs.size > MAX_CHUNKS_PER_CHAPTER` guard, same early `onFailure`. +- `AddChapterScreen` — the counter line already reads + `"$wordCount words · $characterCount characters · $paragraphCount chunks of N"`; + it gains the byte figure against the cap and turns `error`-coloured on the + same rule the chunk count does. +- The two existing hardcoded English literals on that screen (the no-shared-key + message and the too-many-chunks message) move into + `composeApp/src/commonMain/composeResources/values/strings.xml` while the file + is open. They are UI text in a composable and `CLAUDE.md`'s budget is zero + title-case strings with literals reported; a third one added by this work + would be the wrong direction. + +**Test:** extend `ChapterBatchProposalJvmTest`, which already asserts the +paragraph cap's boundary (`MAX_BATCH_SIZE - 1` proposes, `MAX_BATCH_SIZE` +throws). Add the byte boundary the same way: one chapter at the cap proposes, +one a byte over does not. And one that measures the *encoded proposal* for a +cap-sized, 63-chunk chapter and asserts it is under `MAX_PAGE_BYTES` — that is +the assertion that makes the constant honest, and the only one that will catch +it drifting when a tag is added to either kind. + +--- + +## Phase 3 — an index that counts proposals, not rows + +**A day. A fix to the path that exists.** + +The index is signed into the event and cannot be recounted on arrival — the +comment in `addChapter` says exactly why: "a device applying it cannot recount +from a version other members may have added to in a different order". So the +number has to be right at proposal time, and "rows in the table" is not it, +because a proposed chapter is not a row for as long as the quorum takes. + +The information is already available. `FrostSigningRepository` exposes +`observeSessionsForChatRoom`, `getItems(sessionId)` and `unsignedEvents(items)`, +and the last of these exists precisely for this: "A caller wanting an id … needs +it while the first session is still running, and the id is settled at proposal +time." + +```kotlin +// MantraRepository, or a small ChapterIndexing object beside TranslationScaffold +/** + * The index the next chapter of [artifactVersionId] takes, counting chapters + * the room has proposed but not yet signed. + * + * Rows alone are wrong by however many proposals are in flight, which at N = 1 + * is almost never and during an upload is always. A proposed chapter's index is + * already fixed -- it is signed into the unsigned event the session will sign -- + * so it counts. + */ +suspend fun nextChapterIndex(chatRoomId: String, artifactVersionId: String): Int +``` + +Implemented as: rows for the version, plus the unsigned `ChapterEvent`s of every +open session in the room whose `artifactVersionId()` matches, de-duplicated by +event id, and `maxOf(index) + 1` rather than a count — a count is wrong the +moment a gap exists, and after a chapter fails to reach quorum a gap is exactly +what there is. + +Then `AddChapterViewModel` calls it instead of `.size`, and the upload allocates +`base + i` for its i-th file **once**, before proposing anything, rather than +re-asking between sessions. Re-asking would work and is worse: it makes the +upload's numbering depend on how fast the sessions are written. + +**What this does not fix.** Two *devices* proposing at the same moment still +collide — neither sees the other's proposal until it arrives. That is a +distributed-allocation problem and the honest answer is that index collisions +are tolerable: the index orders reading, two chapters at index 4 sort +arbitrarily between themselves and nothing else breaks. Worth a sentence in the +code so the next reader does not take the local fix for a global one. + +**Test** (`jvmTest`, beside `ChapterBatchProposalJvmTest`): propose three +chapters back to back without completing any session, and assert the three +signed indices are 0, 1, 2 — which is the test that fails today. + +--- + +## Phase 4 — the upload screen + +**Three days. The feature.** + +`UploadChaptersRoute` / `UploadChaptersUIState` / `UploadChaptersViewModel` / +`UploadChaptersScreen`, registered in `MantraNavHost`, reached from +`ArtifactDetailScreen` next to the existing "add chapter" `TextButton` +(`ArtifactDetailScreen.kt:165`), with the same `enabled = versions.isNotEmpty()` +condition. + +### It is a review step, not a submit button + +This is the one place the plan departs visibly from the web app, and the reason +is that a signature is not a submission. mantra.press can afford to post twelve +files and redirect, because what it created is the uploader's and the uploader +can fix it. Here, twelve files become twelve requests for the group's own +signature, and anything wrong in them — a filename for a title, a file that +decoded to nothing, a chapter over the byte cap — is wrong in something a quorum +is about to put its name to. + +So picking files does not propose anything. It produces a list, one row per +file: + +| shown | from | +|---|---| +| name, **editable**, defaulting to the filename without its extension | `PickedFile.name` | +| the index it will take | `base + i` from Phase 3 | +| chunks, against `MAX_CHUNKS_PER_CHAPTER` | `Markdown.splitParagraphs(...).size` | +| words, characters, bytes against `MAX_ORIGINAL_TEXT_BYTES` | `Markdown`, Phase 2 | +| a per-row problem, when there is one | below | +| a remove action | — | + +Four per-row problems, each blocking that row only — a bad file should not +cost the other eleven: + +- not text, or not UTF-8 (`ChapterText.Result.NotText`) +- empty after trimming, which is a chapter with nothing to translate +- over the chunk cap, with the advice to split it +- over the byte cap +- **duplicate**: its normalised text matches an existing chapter of this version + or another row in this list. Not blocking — a book can repeat a page — but + warned, because the common cause is picking the same directory twice, and two + chapters with the same text are two different event ids that no later + de-duplication can merge. + +A per-file name field rather than one derived from the text, because the +filename is right often enough to be the default and wrong often enough +(`chapter-01.md`, `ch1_final_v2.md`) that it must be fixable before a quorum +sees it. Where it is empty, fall back to the first markdown heading in the text, +then to the first paragraph trimmed to a line — which is what the web app's +*other* route does (`name: chunks[0].trim()`), and is better than either +extreme. + +### Proposing + +The FAB — `ExtendedFab`, as every primary action in the app is — reads +"propose N chapters", is disabled when any included row has a blocking problem +or `canSign` is false, and on tap: + +``` +for each row, in index order: + propose(chapter + chunks) // the existing batch, one session per chapter + record the session id +then: + propose the scaffolding for all N chapters at once // Phase 5 +then: + navigate to the proposal list, not to a signing screen +``` + +**Sequentially, not concurrently.** Each `proposeSigningBatch` writes a session +and publishes group events; N at once is N interleaved MLS epochs, and +[mls-skipped-keys.md](./mls-skipped-keys.md) is about what a group event that +arrives a moment out of order costs. The upload is not in a hurry. + +**Partial success is the expected outcome, not an error.** If the fourth +proposal fails, the first three are real proposals that the group can still +sign, and nothing can retract them. So the screen does not roll back and does +not navigate away on a failure: it marks the rows that were proposed, leaves the +rest listed with their reason, and offers "propose the remaining". Phase 3's +`nextChapterIndex` means a retry re-reads the base and does not re-use the +indices already proposed. + +**It navigates to `ProposalListScreen`, not to `FrostSigningRoute`.** The single +chapter path goes to the signing screen because there is one session to watch, +and the reasoning in `addChapter` is sound for it ("landing on the list it is not +in would read as a failure"). At N there is no one session, and the list is the +screen that exists for "two proposals need a place that shows both". A snackbar +via `rememberNotifier` carries the count, read above the handler as `CLAUDE.md` +requires. + +### Scope, and what it must say out loud + +Before the picker opens, the screen says what an upload will cost the room: one +proposal per file, each needing a quorum. This is not a nicety. The app's +approval gate is deliberately per-session and deliberately shows every event, +and twelve of them arriving at once is a real imposition on every other member +that the person picking files is the only one in a position to weigh. + +A soft cap belongs here too — refuse to propose more than, say, 20 files in one +go, and say why. Not a protocol limit; a limit on how much of the room's +attention one tap may spend. + +### M3, per `CLAUDE.md` + +`NavigateBackButton` in the app bar's leading slot (it is a pushed screen); +`readableContent()` on the content root; all four states, with `EmptyState` +carrying its own message for "no files picked yet" and `ErrorState` taking a +real `onRetry`; `ScreenStateTransition` wrapping the `when` as the composable's +whole body; spacing from `MaterialTheme.spacing`; every string in `strings.xml` +in sentence case with `%1$s`/`%1$d` arguments; every icon with a decided +`contentDescription`. Finish with `./gradlew :composeApp:m3Audit`. + +--- + +## Phase 5 — scaffold once, not once per chapter + +**Half a day. A fix to the path that exists, and an N-fold saving.** + +`AddChapterViewModel.scaffoldIntoTranslations` is right about needing its own +session — a chapter's paragraph budget should not be spent on the group's +dialects — and wrong about needing one *per chapter*, which only ever happens +during an upload. + +`TranslationScaffold.chaptersOf(translationArtifactVersionIds, chapters, +createdAt)` already takes a list and already builds the full cross product in +translation-then-reading order. So lift the scaffolding out of the per-chapter +loop: collect each chapter's `SourceChapter(id, index)` from its session's +unsigned lead event, and after the last chapter is proposed, propose the whole +cross product in `chunked(MAX_BATCH_SIZE)` batches — which is what `addChapter` +already does across *translations* and what +`TranslationArtifactVersionDetailViewModel` does across chapters. + +Twelve chapters × three dialects: from 12 scaffolding sessions to 1. + +Keep the existing failure policy verbatim, including its reasoning: scaffolding +failure is logged and swallowed, because the chapters are the thing that was +asked for, and a scaffolding row naming a chapter that never reached quorum +fails a foreign key, is logged by `applySignedEvent`, and never becomes a row. +The catch-up on the translation fills the gap afterwards. At N that argument gets +*stronger*, not weaker — one cross-product batch covering twelve chapters is +only as available as its worst item, and some of those chapters may genuinely +fail. + +`AddChapterViewModel` keeps its one-chapter call into the same helper, so there +is one piece of code deciding what a chapter's scaffolding is. + +--- + +## Phase 6 — N proposals, in the room + +**A day. No new screens.** + +The transcript and the proposal list were built for two concurrent sessions — +`ChatMessage.frostSigningSessionId` exists because matching a request against +"any later line of the fulfilling type" broke the moment a chapter and its +scaffolding were open together. Twelve is the same problem at a scale that will +find whatever that fix missed. + +What to check rather than assume: + +- `ProposalListScreen` with twenty open sessions: the ones waiting on the reader + gather at the top, and `ProposedEvent.summarize` gives a chapter a name rather + than "Event of kind 30302". Chapters are summarised today; confirm the + summary is the chapter's *name*, since twelve rows reading "Chapter" are + twelve rows a member cannot choose between. +- Twelve "Review" lines in the transcript, each resolving its own session. + `answeredRequests`/`settledRequests` read `frostSigningSessionId` when both + rows have one; twelve is where a fallback-to-clock path would show. +- Twelve approval gates. No batching of the gate — that is the one thing the + batch-signing note refuses, and for the same reason: "a member who cannot + scroll the whole list should not be able to approve it". What can be done is + make the *list* cheap to work through, which is the point above. + +No new `ChatMessage` type, no new set to add to. + +--- + +## Phase 7 — the splitter, which is a decision about translation + +**Half a day, and it is a measurement rather than code.** + +mantra.press splits with markdown-it block tokens; this app splits on blank +lines. + +```js +// mantra.press: one chunk per block-level token that carries content +md.parse(text).filter(t => t.content).map(t => t.content) +``` + +```kotlin +// here: one chunk per blank-line-separated block +text.split(Regex("(?:\\r?\\n){2,}")).map { it.trim() }.filter { it.isNotEmpty() } +``` + +They were assumed to agree on ordinary prose and to disagree only on converted +files. Run side by side over a corpus — the reference parser as +mantra.press configures it, against the Kotlin rule — they disagree on **almost +everything**, and on two separate axes: + +| input | markdown-it | `splitParagraphs` | +|---|---|---| +| `# Chapter one` ⏎ `First paragraph.` (the converter's shape) | 3 chunks, heading apart | 2 chunks, heading glued to the prose | +| `# Chapter one` ⏎⏎ `First paragraph.` | `"Chapter one"` | `"# Chapter one"` | +| a tight three-item list | 4 chunks — the lead-in and each item | 2 — the lead-in and the whole list | +| `> line one` ⏎ `> line two` | `"line one\nline two"` | `"> line one\n> line two"` | +| a fenced code block | `"code();\n"`, fence markers gone | ``"```\ncode();\n```"`` | +| a 2 × 2 table | 4 chunks — one per cell | 1 — the table's source | +| `---` between two paragraphs | 2 chunks, the rule is not one | 3 — the rule becomes a chunk | +| `Title` ⏎ `=====` (setext) | `"Title"` | `"Title\n====="` | +| a nested list | 2 chunks, one per item | 1 | +| a paragraph with a lazy continuation line | 1 chunk | 1 chunk — agree | + +One row in eleven agrees. The table is reproducible in a dozen lines of node +against `mantra.press/node_modules/markdown-it`, and Phase 8 turns that into +checked-in fixtures. + +**Axis one is boundaries**: how many chunks, which is the obvious half. +**Axis two is the text itself**, which is the half that was missed: markdown-it's `content` +is the leaf block's inline source with the *block* marker removed — no `#`, no +`> `, no `- `, no fence, no setext underline — while inline markup (`*italic*`, +a link's brackets) is kept verbatim. So even the rows where the counts agree +produce different chunk text, and therefore different chunk ids. + +A chunk is the unit a translation is of and the thing the group signs. Axis two +is the one that matters to a translator: handed `"# Chapter one"` they can break +the document by mistyping the `#`, and handed `"Chapter one"` they cannot. + +**The decision: adopt markdown-it's block tokens, boundaries and stripped text +both, for one splitter shared by the upload and the paste form.** The +alternative — markdown-it semantics for uploaded files, blank lines for typed +ones — means the same text yields different chunks depending on how it arrived, +which is indefensible the moment two members do it differently. + +### Three places to diverge from mantra.press on purpose + +Faithful is not the goal; the right unit of translation is. Three of +markdown-it's answers are wrong here, and each should be decided now rather than +inherited. + +**Code blocks should not be chunks.** `fence` and `code_block` carry content, so +mantra.press turns `code();` into a chunk — with a trailing `\n` in its text — +and asks a translator to translate it and the group to sign it as prose. Emit no +chunk for either; the code stays in the chapter's `original_text` and the +producer passes it through untouched. + +**A table should be one chunk, not one per cell.** Four chunks for a 2 × 2 table +is markdown-it's honest reading — cells do contain prose — but each costs an +event with a 64-character id, a 64-character pubkey and a 128-character +signature to carry one word, and a table's alignment is structure that per-cell +translation cannot preserve anyway. One chunk of table source, and the +translator keeps the pipes. + +**A link reference definition should not be a chunk.** mantra.press's parser is +`markdown-it().disable(['link', 'reference'])`. `link` is an *inline* rule, so it +changes neither the block tokens nor an inline token's content; `reference` is a +*block* rule, and disabling it stops `[ref]: https://example.com` being consumed +as a definition, so it falls through to `paragraph` and becomes a translatable +chunk of a URL. That is a side effect of reaching for the wrong rule name, not a +decision. Recognise the form and emit nothing. + +### Why the change is safe to make at any time + +No device ever re-derives chunks from a chapter's text. `ChunkEvent.splitOf` is +called at proposal time only — `AddChapterViewModel` and tests, nowhere on the +apply path — and the chunks travel as their own signed events; +`ChatMessage.applySignedEvent` writes the rows it is given, and the chronicle's +fallback rebuilds them from rows via `toXEvent()` rather than by splitting +anything. So a device on an older splitter receiving a newer-split chapter +applies the chunks it was handed, and there is no skew to negotiate, no +migration, and nothing to re-sign. + +What does *not* change retroactively: chapters already signed keep the chunks +they were signed with. Re-splitting one later means re-proposing it as a new +chapter with new ids. So this lands before the first real upload, not after. + +--- + +## Phase 8 — a block splitter that agrees with markdown-it + +**Three days, and the fixtures are most of the value.** + +The implementation of Phase 7's decision. One new function, five call sites +moved together, one thing the app has never had, and an oracle instead of an +opinion. + +### The function + +```kotlin +// press.mantra.compose.text.Markdown +/** + * The blocks a chapter's markdown splits into, one chunk each. + * + * A chunk is a *leaf* block's inline text with its block marker removed: the + * words a translator is given and the words the group signs. Containers -- + * lists, blockquotes, tables -- contribute nothing of their own; a thematic + * break and a code block contribute nothing at all. + * + * This is markdown-it's block token stream, which is what mantra.press + * translates against, with the three deliberate departures in + * docs/chapter-uploads.md. Inline markup is kept verbatim, because emphasis and + * links are the translator's to carry; block markers are not, because a `#` + * mistyped into a translation breaks the document. + * + * Its output is hashed into event ids a quorum signs, so it is pinned by the + * golden fixtures in MarkdownBlockFixtures rather than by this comment. + */ +fun splitBlocks(text: String): List + +data class Block( + val kind: Kind, // Paragraph, Heading(level), ListItem, Quote, Table + /** The block marker, kept so a document can be put back together. */ + val markup: String, + /** The inline source, marker removed. This is the chunk's text. */ + val text: String, +) +``` + +A `Block` rather than a `String`, because Phase 7's stripping decision means the +`#` and the `- ` have to be kept *somewhere* for the producer below, and the +chapter's `original_text` is the wrong place to look them up from — matching +text back to its marker is the string-equality bug described two sections down. + +The block starts to recognise, which is markdown-it's block ruler minus the +rules Phase 7 drops: ATX heading, setext heading, fenced code (skipped), +indented code (skipped), blockquote, bullet and ordered list items including +nesting, thematic break (skipped), table (one block), link reference definition +(skipped), paragraph with lazy continuation. + +### Hand-rolled, with the reference implementation as the oracle + +`org.jetbrains:markdown` is multiplatform and CommonMark-compliant and would do +the parsing. It is the wrong dependency *here*, for one reason that is specific +to this function: its output is hashed into ids that a quorum signs, so a +version bump that moves one boundary silently re-splits every chapter proposed +afterwards, and nothing in the app would notice. A hand-rolled scanner over the +block starts above is a few hundred lines whose behaviour is ours to pin. + +That is only defensible with an oracle, so: + +**`MarkdownBlockFixtures` — input/expected pairs generated by markdown-it +itself, checked in as test data.** A script under `docs/scripts/` runs the +reference parser over a corpus (the converter's output, a hand-typed chapter, the +pathological cases in Phase 7's table) and emits the expected blocks; the +`commonTest` suite asserts `splitBlocks` reproduces them, with the three +departures listed as explicit exceptions rather than silently absent. Fidelity +is then measured against the implementation it claims to agree with, which is +the same standard `MAX_PAGE_BYTES` sets for itself. + +Being hand-rolled, it is a documented *subset* of CommonMark and should say so: +no HTML blocks (markdown-it's default is `html: false` and the `
` in the +corpus comes back as a paragraph, so this matches), no footnotes, no nested +blockquote-in-list beyond one level. Each omission is a fixture asserting what +happens instead, not a gap. + +### The producer, which the app has never had + +Stripping block markers from chunks is only safe if a translated chapter can be +put back together, and nothing in `composeApp/src` does that today — there is no +equivalent of mantra.press's `lib/markdown-producer/index.js`. It is part of this +phase, because Phase 7's decision is incomplete without it. + +```kotlin +object MarkdownProducer { + /** The chapter's markdown with each chunk's translation in place of its text. */ + fun produce(originalText: String, translations: Map): String +} +``` + +Walk `splitBlocks(originalText)`, substitute by **index**, and reassemble with +each `Block.markup`. Substituting by index rather than by text is the one place +this is strictly better than the thing it mirrors: mantra.press matches with +`translationChunks.find(tc => tc.text == token.content)`, so a chapter with two +identical paragraphs puts the first one's translation into both. Chunks here +already carry an `IndexTag`, so position is available and exact. + +Two outputs, as mantra.press has: markdown, and whatever the reader needs. Only +markdown is in scope here — the app renders chunks itself today — but the walk +is shared. + +### Five call sites, moved together + +`splitParagraphs` is called from five places and they have to agree, or the +count shown to a member differs from the chunks in the batch they are approving: + +| call site | change | +|---|---| +| `ChunkEvent.splitOf` | splits into `Block`s, takes `.text` for the chunk | +| `AddChapterViewModel.addChapter` | the cap check counts blocks | +| `AddChapterScreen` | the live counter counts blocks | +| `ProposedEvent.summarize` | **stops splitting** — see below | +| `ChapterChunkSplitTest` | its parity assertion still holds, against the new function | + +`splitParagraphs` goes rather than delegating. A second splitter left in the +codebase is a second answer to "what is a chunk", and the next caller will pick +whichever they find first. + +**`ProposedEvent.summarize` has a latent bug this phase should fix rather than +port.** It reports a proposed chapter's chunk count by re-splitting the +chapter's text (`ProposedEvent.kt:77`), so the number a member reads in the +approval gate is derived rather than observed — and across a splitter change it +is simply wrong, since the batch was built by the proposer's splitter and +counted by the reader's. The count is sitting right there: the session's items +minus the lead. Count those. + +### What it costs, and the phase it moves + +Block splitting produces **more** chunks than blank-line splitting — every +heading and every list item is its own now — so both of Phase 2's limits bind +sooner. A list-heavy chapter can double or triple its chunk count against +`MAX_CHUNKS_PER_CHAPTER = 63`, and every extra chunk adds an event envelope +(~370 bytes before it says anything) to the proposal. + +So **`MAX_ORIGINAL_TEXT_BYTES` must be measured after this lands, not before.** +Either take this phase ahead of Phase 2, or treat Phase 2's constant as +provisional and re-run its encoded-proposal measurement here. The second is +cheaper and the measurement is the same test either way. + +### Tests + +`commonTest`, all of it pure: + +- `MarkdownBlockTest` against the generated fixtures — the bulk of it, and the + part worth reviewing. +- the three departures, asserted as departures: a fenced block and an indented + block yield no chunk, a table yields one, a reference definition yields none. +- inline markup survives: `*italic*` and `[text](url)` are in the chunk's text. +- `MarkdownProducerTest`: round-trip with no translations reproduces the input + byte for byte (the assertion that catches every markup-reassembly bug at + once), and a chapter with two identical paragraphs translated differently + comes back with both translations in the right places — the case mantra.press + gets wrong. +- `ChapterChunkSplitTest` keeps its existing round-trip and id-stability + assertions, now with the converter's output shape as a fixture. + +--- + +## Phase 9 — tests and rollout + +**A day, and the list is short because most of it is in the phases.** + +New, per phase: `ChapterTextTest` (decode), the byte-cap boundary and the +encoded-proposal measurement in `ChapterBatchProposalJvmTest`, the +three-proposals-in-flight index test, `MarkdownBlockTest` against the generated +fixtures and `MarkdownProducerTest`'s byte-for-byte round trip, and a +`ChapterUploadJvmTest` beside the +existing batch tests that drives the view model over a real in-memory database +and asserts the whole shape: N files in, N sessions out with one lead chapter +each, indices `base..base+N-1`, chunks matching each file's paragraphs, one +scaffolding session for the lot, and — the one that matters — that a failure on +file 4 leaves files 1–3 proposed and recoverable rather than rolled back. + +`jvmTest`, not `commonTest`, because it is Room-backed; `./gradlew +:composeApp:jvmTest`, per the project's build notes. + +Rollout needs no flag. The chapter and chunk kinds, their tags and their apply +branches are unchanged, so an older build receives an uploaded chapter as the +chapter it is. Two things do change what a peer sees, and neither needs +negotiation: a room will see N proposals where it used to see one (which an +older build handles — it is N ordinary sessions), and Phases 7–8 change chunk +boundaries and chunk text for new chapters only — safely, because nothing +re-derives chunks from a chapter's text: `splitOf` runs at proposal time and the +chronicle rebuilds from rows. The byte cap of Phase 2 is the only +change that makes the app *refuse* something it used to attempt, and what it +used to attempt was a session that stalled. + +Order: 1 → 2 → 3 before 4; 5 and 6 after 4; 7 → 8 whenever, but before the +first real upload, since a chapter already signed keeps the chunks it was signed +with. Phases 2, 3, 5 and 8 are each shippable alone and each fix something that +is wrong today at N = 1 — the index collision, the unchecked proposal size, the +scaffolding fan-out and the splitter — which is the argument for landing them as +their own commits rather than inside the feature. + +--- + +## Appendix — considered and rejected + +**One session for the whole upload.** The three reasons are in § The constraint. +The size cap alone kills it at two chapters. + +**One chapter event per upload, with the files concatenated.** Fits, signs once, +and destroys the thing being modelled: a chapter is the unit of work assigned to +a translator, and `ChapterEvent` carries one index. + +**A manifest: sign one event committing to N chapter ids, derive the chapters +from it.** Cheapest option on this page, and already argued down in +[frost-batch-signing.md](./frost-batch-signing.md) — each chapter stops carrying +its own verifiable signature, and the point of +[shared-key-derivation.md](./shared-key-derivation.md) is that a reader holding +one signed thing can check it against the room it was found in without a lookup. +Chapters are read apart; they get their own signatures. + +**Derive the chunks on arrival instead of signing them.** This is what the app +did before batch signing, and it was deliberately changed: "a chunk that can be +checked on its own is worth more than one that can only be checked by +re-deriving it". It would double the number of uploadable paragraphs, which is +not worth it. Note the asymmetry, though — it *is* the right answer for the +translation scaffolding, which is why that is a separate session rather than +separate events. + +**A remote upload: post the files somewhere and have the group sign a URL.** No. +An artifact's `url` tag is a reference to a published work; the text the group +signs is the text, and a chapter whose words live behind a link is a chapter the +group has not actually agreed to. + +**An uploads table to make an interrupted upload resumable.** A schema version +for state that Phase 3 can read off the open sessions instead. The proposals are +the record of what was uploaded; `unsignedEvents` is how to read it. If resume +ever needs more than "re-pick the files and the duplicate check tells you what +already went", revisit — but it will need a reason that is not available today. + +**Mirroring mantra.press's xlsx import** (`POST /projects/:id/import`, +`server/router/projects/index.js:1565`) — a spreadsheet of translations rather +than chapter markdown. A different feature on the translation side of the +hierarchy, with the same batching argument and a different parser. Out of scope +here, and the plan above is the shape it would follow. From eb191d945acc08db63b0c54769c65409f698cb44 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:29:20 +0200 Subject: [PATCH 02/32] feat(chapters): a chapter can be loaded from a file Phase 1 of docs/chapter-uploads.md. The picker and the decode, with one file going into the add-chapter form that already works -- so the platform seam is exercised on its own, before anything knows about several chapters at once. Three actuals rather than FileKit. This is the app's only dialog, the calls are a dozen lines each, and the project already keeps four platform seams of this shape; the library's Compose-version coupling buys nothing here, and the ios target cannot be compiled on a linux host either way. ChapterText.decode is the part that matters. The web app does `file.data.toString()`, where a .docx becomes a chapter of zip-header garbage the uploader can go back and delete; here the next thing that happens to the text is that a quorum signs it. So: strict UTF-8, because a lenient decode hides U+FFFD inside something permanent; a NUL sniff before decoding, because mostly-ASCII binary decodes cleanly; the byte-order mark stripped and CRLF normalised, because both change the bytes without changing a word and the same prose from two machines has to give the same chunk ids. The pickers stop one byte past the cap rather than at it, so an over-size file reads as over-size instead of as a chapter with its ending missing. Android registers both open contracts so asking for one file does not offer a picker that returns six. The dialog title is a parameter read from the catalogue, since stringResource is composable and a FileDialog is not. ChapterNaming came out of the two answers the web app gives on its two routes -- the filename on the upload, the first chunk on the paste form -- both of which are right some of the time. The filename minus extension and ordinal prefix, falling through to a heading and then the first line, because `01.md` is not a chapter title and `03 - The Wreck.md` is. FilePicker.ios.kt is unverified: the ios targets are declared only on macos, so it is not compiled here. Written to the shape of the eight .ios.kt actuals beside it and says so in its KDoc. A stub returning no files would read as a working picker that a reader's chapters vanish into. 22 new tests, all pure; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../compose/files/FilePicker.android.kt | 109 ++++++++++++++ .../composeResources/values/strings.xml | 4 + .../press/mantra/compose/files/FilePicker.kt | 72 ++++++++++ .../mantra/compose/text/ChapterNaming.kt | 66 +++++++++ .../press/mantra/compose/text/ChapterText.kt | 97 +++++++++++++ .../compose/ui/composable/AddChapterScreen.kt | 58 ++++++++ .../mantra/compose/text/ChapterNamingTest.kt | 68 +++++++++ .../mantra/compose/text/ChapterTextTest.kt | 126 ++++++++++++++++ .../mantra/compose/files/FilePicker.ios.kt | 135 ++++++++++++++++++ .../mantra/compose/files/FilePicker.jvm.kt | 91 ++++++++++++ 10 files changed, 826 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt create mode 100644 composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt new file mode 100644 index 00000000..fe82857d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt @@ -0,0 +1,109 @@ +package press.mantra.compose.files + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import co.touchlab.kermit.Logger +import press.mantra.compose.text.ChapterText +import java.io.InputStream + +/** + * The Storage Access Framework, which needs no manifest permission at all. + * + * `OpenDocument` and `OpenMultipleDocuments` are a handoff to the system + * picker: the app never sees the file system, and the grant it comes back with + * covers exactly the documents the reader chose. `READ_EXTERNAL_STORAGE` and + * its API 33 replacements are for apps that browse storage themselves, which + * this does not. + * + * **Both contracts are registered, not just the multiple one.** Registering + * only `OpenMultipleDocuments` and ignoring the flag would work, and would mean + * that the one-file case -- loading a chapter into the add form -- offers a + * picker that lets the reader select six. Asking for one thing and accepting + * six is how a form ends up quietly discarding five of them. + * + * The mime types are deliberately loose. Markdown has no registered type: + * providers report `.md` as `text/markdown`, `text/plain`, `application/octet-stream` + * or nothing at all depending on the provider and the Android version, so a + * narrow filter hides the very files this is for. Filtering on `text/*` plus + * `*/*` and letting `ChapterText.decode` be the judge is both more permissive + * at the picker and stricter about what becomes a chapter. + * + * `title` is unused. `Intent.EXTRA_TITLE` is honoured by `CreateDocument`, where + * it is the suggested filename, and ignored by the open contracts -- the system + * picker titles itself. Nothing is lost by taking the parameter; something would + * be lost by making desktop's title a literal to avoid it. + */ +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val context = LocalContext.current + val resolver = remember(context) { context.contentResolver } + + val single = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + onPicked(listOfNotNull(uri?.let { resolver.readPickedFile(it) })) + } + + val multiple = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + onPicked(uris.mapNotNull { resolver.readPickedFile(it) }) + } + + return remember(single, multiple) { + FilePicker { wantsMultiple -> + if (wantsMultiple) multiple.launch(MIME_TYPES) else single.launch(MIME_TYPES) + } + } +} + +private val MIME_TYPES = arrayOf("text/*", "*/*") + +/** + * Reads a picked document, stopping one byte past the cap. + * + * One byte *past*, so an over-size file comes back over-size rather than + * truncated to exactly the limit -- a truncated chapter would decode cleanly + * and be proposed with its ending missing. + * + * A failure here is a document that went away between being picked and being + * read, or a provider that threw; logged and dropped, because the alternative + * is failing the whole selection over one file. + */ +private fun ContentResolver.readPickedFile(uri: Uri): PickedFile? = + runCatching { + val bytes = openInputStream(uri)?.use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) } + ?: return null + PickedFile(name = displayName(uri) ?: uri.lastPathSegment.orEmpty(), bytes = bytes) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked document $uri", error) + }.getOrNull() + +private fun ContentResolver.displayName(uri: Uri): String? = + runCatching { + query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.getOrNull() + +private fun InputStream.readAtMost(limit: Int): ByteArray { + val buffer = ByteArray(limit) + var filled = 0 + while (filled < limit) { + val read = read(buffer, filled, limit - filled) + if (read < 0) break + filled += read + } + return buffer.copyOf(filled) +} + +private const val TAG = "FilePicker" diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45be57b2..f6e0cc25 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -491,4 +491,8 @@ You already have a chat with %1$s. Checking whether %1$s can receive messages here. %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. + Load from file + Choose chapter files + That file isn't text. A chapter is markdown or plain text. + That file is too large to be a chapter. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt new file mode 100644 index 00000000..4640839f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt @@ -0,0 +1,72 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable + +/** + * A file the reader picked, as its name and its bytes. + * + * Bytes rather than a path or an open stream, and that is a decision rather + * than laziness. A path is meaningless on iOS the moment the picker's security + * scope closes, so a path would be a handle that works on two platforms and + * expires on the third. A stream would push the size cap and the decode into + * whichever coroutine happened to read it. Chapters are tens of kilobytes; + * `ChapterText.MAX_FILE_BYTES` bounds the read and the whole file fits in hand. + * + * [name] is for naming the chapter, not for deciding what the file is -- that + * is `ChapterText.decode`'s job, by looking at the bytes. On android the name + * comes from the document provider and can be absent or a lie; it is a default + * a person edits, never a check. + */ +data class PickedFile( + val name: String, + val bytes: ByteArray, +) { + // ByteArray makes the generated equals/hashCode identity-based, which is + // wrong for a data class that will sit in a list in UI state and be + // compared across recompositions. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PickedFile) return false + return name == other.name && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * name.hashCode() + bytes.contentHashCode() +} + +/** Opens the platform's file chooser. */ +fun interface FilePicker { + fun launch(multiple: Boolean) +} + +/** + * The platform's file chooser, wired to hand [onPicked] what came back. + * + * `expect` rather than a dependency. The obvious alternative is FileKit, which + * covers all three targets from common code -- but this is the only thing in + * the app that needs a dialog, the three calls below are a dozen lines each, + * and the project already keeps four platform seams of exactly this shape + * (`PlatformContext`, `PlatformDatabaseBuilder`, `platformReducedMotion`, + * `platformThemeContrast`). A dependency whose Compose version has to track + * ours is a poor trade for that. + * + * **Nothing is read beyond `ChapterText.MAX_FILE_BYTES`.** Each actual stops + * there, so picking a video by mistake costs a bounded read rather than the + * whole file, and the over-size file still arrives -- with more bytes than the + * cap allows -- so that `decode` can say `TooLarge` instead of the picker + * silently dropping it. + * + * Cancelling hands back an empty list, which is how a caller tells "no files" + * from "files that would not decode". + * + * [title] is the dialog's own title, read from the string catalogue by the + * caller because `stringResource` is composable and the dialog is not. Only + * desktop uses it: android's Storage Access Framework and iOS's document + * browser each title themselves and offer no way to say otherwise. Taken on all + * three anyway, so that the one platform which shows text to the reader gets it + * from the catalogue like every other string in the app. + */ +@Composable +expect fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt new file mode 100644 index 00000000..f992335e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt @@ -0,0 +1,66 @@ +package press.mantra.compose.text + +/** + * What to call a chapter that arrived as a file. + * + * The web app this mirrors has two answers and uses a different one per route: + * the upload names a chapter `file.name` and the paste form names it + * `chunks[0].trim()`. Both are right some of the time. `chapter-01.md` is a + * filename, not a title, and a chapter whose first paragraph is prose has a + * better name in its own text; a chapter whose file is called + * `03 - The Wreck of the Hesperus.md` does not. + * + * So: the filename first, because somebody chose it, and the text only when the + * filename says nothing a reader would want -- and a heading in the text before + * a paragraph of it, because a heading is a title and a paragraph is the + * beginning of the work. + * + * Whatever this returns is a **default in an editable field**, never the final + * answer. A quorum is asked to sign the name along with the chapter, so the + * person uploading has to be able to fix it first. + */ +object ChapterNaming { + + /** Past this, a name is the beginning of a paragraph rather than a title. */ + private const val MAX_DERIVED_LENGTH = 80 + + private val LEADING_ORDINAL = Regex("^[\\s\\-_.0-9]+") + + fun forFile(fileName: String, markdown: String): String = + fromFileName(fileName) ?: fromMarkdown(markdown) ?: "" + + /** + * The filename with its extension and its ordinal prefix removed. + * + * `03 - The Wreck.md` is `The Wreck`; `chapter-01.md` is `chapter`, which is + * a poor title but an honest reflection of a poor filename and is the one + * the person will edit. What is dropped entirely is a name that is *only* + * an ordinal -- `01.md`, `1.txt` -- because "01" as a chapter title is worse + * than falling through to the text. + */ + private fun fromFileName(fileName: String): String? = + fileName.substringBeforeLast('.', fileName) + .replace('_', ' ') + .replace(LEADING_ORDINAL, "") + .trim() + .takeIf { it.isNotEmpty() } + + /** + * The first heading in the text, or failing that its first line. + * + * Reads the markdown rather than [Markdown]'s split, because this runs while + * a file is being looked at rather than while a chapter is being proposed, + * and a heading's `#` has to be stripped either way. + */ + private fun fromMarkdown(markdown: String): String? { + val lines = markdown.lineSequence().map { it.trim() }.filter { it.isNotEmpty() } + + val heading = lines.firstOrNull { it.startsWith("#") } + ?.trimStart('#') + ?.trim() + ?.takeIf { it.isNotEmpty() } + if (heading != null) return heading.take(MAX_DERIVED_LENGTH) + + return lines.firstOrNull()?.take(MAX_DERIVED_LENGTH) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt new file mode 100644 index 00000000..1f281deb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt @@ -0,0 +1,97 @@ +package press.mantra.compose.text + +/** + * Turning the bytes of a picked file into the markdown a chapter is made of. + * + * Deliberately not `bytes.decodeToString()`, which is what the web app this + * mirrors does (`file.data.toString()` in `server/router/versions/index.js`). + * There, a `.docx` dropped into the form becomes a chapter of zip-header + * garbage that the uploader can go back and delete. Here the next thing that + * happens to this text is that a quorum is asked to sign it, so what cannot be + * read as prose has to be refused at the door rather than corrected afterwards. + * + * All four rules below exist because of that one difference. None of them is + * about being tidy; each is about something that would otherwise be permanent. + */ +object ChapterText { + + /** + * The most bytes a picked file may be. + * + * A crash guard, not the real limit -- what a chapter may actually carry is + * bounded by what one signing proposal can put on the wire, which is far + * tighter. This is here so that picking a video by mistake fails + * on a comparison rather than on a megabyte-counting read, and so the + * platform pickers have a number to stop reading at. + */ + const val MAX_FILE_BYTES: Int = 1 * 1024 * 1024 + + /** How far into a file to look for the NUL that says it is not text. */ + private const val SNIFF_BYTES: Int = 8 * 1024 + + sealed interface Result { + /** Decoded, BOM-stripped, newline-normalised markdown. */ + data class Text(val markdown: String) : Result + + /** Not decodable as UTF-8, or carrying a NUL. */ + data object NotText : Result + + /** Over [MAX_FILE_BYTES]. */ + data object TooLarge : Result + } + + fun decode(bytes: ByteArray): Result { + if (bytes.size > MAX_FILE_BYTES) return Result.TooLarge + + // Sniffed before decoding, not after. A file of UTF-16 text decodes as + // invalid UTF-8 and would be refused anyway, but a file of mostly-ASCII + // binary -- a zip's local header, a PDF's preamble -- can decode + // cleanly and only reads as prose if nobody looks for the NUL. + if (hasNul(bytes)) return Result.NotText + + // Strict, so an invalid sequence is refused rather than replaced. A + // lenient decode substitutes U+FFFD, which is invisible in a text + // field, is signed into the chapter's `original_text` tag, and is then + // part of the group's record of the work for good. + val decoded = runCatching { + bytes.decodeToString(throwOnInvalidSequence = true) + }.getOrElse { return Result.NotText } + + return Result.Text(normalise(decoded)) + } + + /** + * The form of a chapter's markdown that is safe to hash. + * + * Two rewrites, both of which change the text's bytes without changing a + * word of it, which is exactly why they belong here rather than at a call + * site: + * + * **The byte-order mark goes.** A leading `U+FEFF` is invisible in the form + * and in the chapter, and it is part of the first chunk's text -- so the + * same opening paragraph saved by two editors is two different chunk ids, + * and a translation of one does not match the other. + * + * **CRLF becomes LF.** [Markdown]'s paragraph rule already tolerates `\r\n`, + * so splitting is unaffected; what is affected is the `original_text` tag, + * which would carry the carriage returns. The same chapter uploaded from + * Windows and from Linux would be two different events over identical + * prose. A lone `\r` -- classic Mac line endings, and what some converters + * still emit -- is normalised too, for the same reason. + * + * Public because the duplicate check on an upload compares chapters by + * their text, and it has to compare the form that will be signed. + */ + fun normalise(text: String): String = + text.removePrefix("") + .replace("\r\n", "\n") + .replace("\r", "\n") + + private fun hasNul(bytes: ByteArray): Boolean { + val end = minOf(bytes.size, SNIFF_BYTES) + for (index in 0 until end) { + if (bytes[index] == 0.toByte()) return true + } + return false + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index ccc6a166..9465b790 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -6,12 +6,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -19,10 +22,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -36,6 +41,9 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.files.rememberFilePicker +import press.mantra.compose.text.ChapterNaming +import press.mantra.compose.text.ChapterText import press.mantra.compose.text.Markdown import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute @@ -55,8 +63,13 @@ import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_ import mantra.composeapp.generated.resources.propose_chapter import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter import mantra.composeapp.generated.resources.add_chapter_to +import mantra.composeapp.generated.resources.choose_chapter_files +import mantra.composeapp.generated.resources.load_from_file +import mantra.composeapp.generated.resources.that_file_is_too_large +import mantra.composeapp.generated.resources.that_file_isn_t_text import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.NavigateBackButton @@ -107,6 +120,42 @@ fun AddChapterScreen( val nameFieldState = rememberTextFieldState() val originalTextFieldState = rememberTextFieldState() + // Read above the handler, not inside it: `rememberNotifier` and + // `stringResource` are composable and a click lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val notText = stringResource(Res.string.that_file_isn_t_text) + val tooLarge = stringResource(Res.string.that_file_is_too_large) + + // One file, into the form that already works. Picking several + // chapters at once is the upload screen's job; here the point is + // to stop a chapter having to be pasted through a keyboard. + val picker = rememberFilePicker( + title = stringResource(Res.string.choose_chapter_files) + ) { files -> + val file = files.firstOrNull() ?: return@rememberFilePicker + + when (val decoded = ChapterText.decode(file.bytes)) { + is ChapterText.Result.Text -> { + originalTextFieldState.setTextAndPlaceCursorAtEnd(decoded.markdown) + // The filename only fills a name nobody has typed. + // Overwriting one that was typed would make loading a + // file a destructive act on a field beside it. + if (nameFieldState.text.isBlank()) { + nameFieldState.setTextAndPlaceCursorAtEnd( + ChapterNaming.forFile(file.name, decoded.markdown) + ) + } + // No snackbar on success. The text arriving in the + // field below is the feedback, and a message over + // the top of it would say less than the thing + // itself. + } + + ChapterText.Result.NotText -> notify(notText) + ChapterText.Result.TooLarge -> notify(tooLarge) + } + } + // Live counts / paragraph (chunk) preview from the markdown text. val originalText = originalTextFieldState.text.toString() val wordCount = Markdown.wordCount(originalText) @@ -221,6 +270,15 @@ fun AddChapterScreen( placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) }, ) + TextButton(onClick = { picker.launch(multiple = false) }) { + Icon( + Icons.Default.UploadFile, + contentDescription = "Load the chapter from a file" + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.load_from_file)) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth().weight(1f), state = originalTextFieldState, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt new file mode 100644 index 00000000..6bd643d3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt @@ -0,0 +1,68 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChapterNamingTest { + + private val body = "The first paragraph.\n\nThe second.\n" + + @Test + fun `a filename somebody chose is the name`() { + assertEquals( + "The Wreck of the Hesperus", + ChapterNaming.forFile("The Wreck of the Hesperus.md", body), + ) + } + + @Test + fun `an ordinal prefix is dropped`() { + assertEquals("The Wreck", ChapterNaming.forFile("03 - The Wreck.md", body)) + assertEquals("The Wreck", ChapterNaming.forFile("1. The Wreck.markdown", body)) + } + + @Test + fun `underscores read as spaces`() { + assertEquals("the wreck", ChapterNaming.forFile("the_wreck.txt", body)) + } + + @Test + fun `a filename that is only an ordinal falls through to the text`() { + assertEquals("The first paragraph.", ChapterNaming.forFile("01.md", body)) + } + + @Test + fun `a heading in the text beats the first paragraph`() { + assertEquals( + "Chapter one", + ChapterNaming.forFile("1.md", "# Chapter one\n\nThe first paragraph.\n"), + ) + } + + @Test + fun `a heading glued to its paragraph is still found`() { + // The shape mantra.press's TeX converter emits: a heading and the + // paragraph under it separated by a single newline. + assertEquals( + "Chapter one", + ChapterNaming.forFile("1.md", "# Chapter one\nThe first paragraph.\n"), + ) + } + + @Test + fun `a derived name is cut to a title's length`() { + val long = "w ".repeat(200) + + assertEquals(80, ChapterNaming.forFile("1.md", long).length) + } + + @Test + fun `nothing to go on gives an empty name for the form to ask about`() { + assertEquals("", ChapterNaming.forFile("01.md", " \n\n \n")) + } + + @Test + fun `a file with no extension keeps its whole name`() { + assertEquals("CHAPTER ONE", ChapterNaming.forFile("CHAPTER ONE", body)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt new file mode 100644 index 00000000..712ee976 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt @@ -0,0 +1,126 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The four rules [ChapterText.decode] exists for, and the one property that + * makes them worth having: the same prose from two machines has to come out as + * the same bytes, because those bytes become a chunk id a quorum signs. + */ +class ChapterTextTest { + + private fun decoded(bytes: ByteArray): String { + val result = ChapterText.decode(bytes) + assertTrue(result is ChapterText.Result.Text, "expected text, got $result") + return result.markdown + } + + @Test + fun `plain utf8 markdown decodes unchanged`() { + val markdown = "# Chapter one\n\nThe first paragraph.\n" + assertEquals(markdown, decoded(markdown.encodeToByteArray())) + } + + @Test + fun `multi byte characters survive`() { + // Setswana and a few of the diacritics a translation will actually carry. + val markdown = "Ke a leboga.\n\nŠŌÜ — ôô.\n" + assertEquals(markdown, decoded(markdown.encodeToByteArray())) + } + + @Test + fun `an invalid utf8 sequence is refused rather than replaced`() { + // 0xFF is not a legal lead byte anywhere in UTF-8. + val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42) + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes)) + } + + @Test + fun `a replacement character is never introduced`() { + val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42) + val lenient = bytes.decodeToString() + + // The point of the rule: a lenient decode succeeds and hides the damage + // inside text that would then be signed. + assertTrue(lenient.contains('�')) + assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes)) + } + + @Test + fun `a byte order mark is stripped`() { + val withBom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + + "First paragraph.".encodeToByteArray() + + assertEquals("First paragraph.", decoded(withBom)) + } + + @Test + fun `the same chapter from windows and from linux decodes identically`() { + val crlf = "# Chapter one\r\n\r\nThe first paragraph.\r\nIts second line.\r\n" + val lf = "# Chapter one\n\nThe first paragraph.\nIts second line.\n" + + assertEquals(decoded(lf.encodeToByteArray()), decoded(crlf.encodeToByteArray())) + // And the thing the rule is actually protecting: without it the two are + // different bytes over identical prose, so different chunk ids. + assertNotEquals(crlf, lf) + } + + @Test + fun `a lone carriage return is normalised too`() { + assertEquals("one\ntwo", decoded("one\rtwo".encodeToByteArray())) + } + + @Test + fun `splitting is unaffected by which newlines arrived`() { + val crlf = decoded("One.\r\n\r\nTwo.\r\n\r\nThree.".encodeToByteArray()) + + assertEquals(listOf("One.", "Two.", "Three."), Markdown.splitParagraphs(crlf)) + } + + @Test + fun `a nul byte means the file is not text`() { + val zipHeader = byteArrayOf(0x50, 0x4B, 0x03, 0x04, 0x00, 0x00) + + "docx-ish".encodeToByteArray() + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(zipHeader)) + } + + @Test + fun `a nul past the sniff window is not looked for`() { + // Documents the limit rather than claiming more than it does: the sniff + // reads 8 KiB, and a file whose first 8 KiB are prose reads as prose. + val bytes = "a".repeat(9 * 1024).encodeToByteArray() + byteArrayOf(0x00) + + assertTrue(ChapterText.decode(bytes) is ChapterText.Result.Text) + } + + @Test + 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. + val utf16 = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x41, 0x00, 0x42, 0x00) + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(utf16)) + } + + @Test + fun `a file at the cap decodes and one byte over does not`() { + val atCap = ByteArray(ChapterText.MAX_FILE_BYTES) { 'a'.code.toByte() } + val overCap = ByteArray(ChapterText.MAX_FILE_BYTES + 1) { 'a'.code.toByte() } + + assertTrue(ChapterText.decode(atCap) is ChapterText.Result.Text) + assertEquals(ChapterText.Result.TooLarge, ChapterText.decode(overCap)) + } + + @Test + 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/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt new file mode 100644 index 00000000..a1412fc4 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt @@ -0,0 +1,135 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIViewController +import platform.UniformTypeIdentifiers.UTTypeData +import platform.UniformTypeIdentifiers.UTTypePlainText +import platform.darwin.NSObject +import press.mantra.compose.text.ChapterText + +/** + * `UIDocumentPickerViewController`, presented over whatever is on screen. + * + * **Unverified on the host this was written on.** The two ios targets are + * declared only on macos -- see the gate in `composeApp/build.gradle.kts` and + * the reason in `docs/jvm-target.md` -- so this file is not compiled on linux + * and has never been run. It is written to the same shape as the other eight + * `.ios.kt` actuals in this source set and should be read on a mac before it is + * trusted. The alternative, a stub that hands back no files, would be worse: + * it reads as a working picker that the reader's chapters vanish into. + * + * `forOpeningContentTypes` rather than the deprecated `initWithDocumentTypes`, + * which takes UTI strings and was removed from the iOS 14 API surface in all + * but name. + * + * **The types are `UTTypePlainText` and `UTTypeData`**, for the reason android's + * are loose: markdown has no registered UTI, so `.md` resolves to + * `public.plain-text` on some devices and to `public.data` on others depending + * on what else is installed. Offering both means the files this exists for are + * selectable; `ChapterText.decode` is what decides whether they are chapters. + * + * **The delegate is held by the composition, not by the view controller.** UIKit + * holds its delegate weakly, so a delegate created inline is collected before + * the reader finishes choosing and the callback never fires -- the classic + * version of this bug. `remember` is what keeps it alive, and is why it is + * built here rather than inside `launch`. + */ +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val delegate = remember(onPicked) { PickerDelegate(onPicked) } + + return remember(delegate) { + FilePicker { multiple -> + val picker = UIDocumentPickerViewController( + forOpeningContentTypes = listOf(UTTypePlainText, UTTypeData), + asCopy = true, + ) + picker.allowsMultipleSelection = multiple + picker.delegate = delegate + + val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + if (presenter == null) { + Logger.withTag(TAG).e("No root view controller to present the document picker from") + onPicked(emptyList()) + } else { + presenter.topmost().presentViewController(picker, animated = true, completion = null) + } + } + } +} + +/** + * `asCopy = true` above puts the chosen files in the app's own temporary + * directory, which is what makes reading them afterwards legal without + * `startAccessingSecurityScopedResource`. It is also why [PickedFile] carries + * bytes rather than a url: the copy is the app's to read now and the system's + * to delete whenever it likes. + */ +@OptIn(ExperimentalForeignApi::class) +private class PickerDelegate( + private val onPicked: (List) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*>, + ) { + onPicked(didPickDocumentsAtURLs.filterIsInstance().mapNotNull(::readPickedFile)) + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + onPicked(emptyList()) + } +} + +/** As on the other two platforms: one byte past the cap, so over-size reads as over-size. */ +@OptIn(ExperimentalForeignApi::class) +private fun readPickedFile(url: NSURL): PickedFile? = + runCatching { + val data = NSData.dataWithContentsOfURL(url) ?: return null + PickedFile( + name = url.lastPathComponent ?: "", + bytes = data.toByteArray(limit = ChapterText.MAX_FILE_BYTES + 1), + ) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked document $url", error) + }.getOrNull() + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(limit: Int): ByteArray { + val size = minOf(length.toInt(), limit) + if (size == 0) return ByteArray(0) + val out = ByteArray(size) + out.usePinned { pinned -> + platform.posix.memcpy(pinned.addressOf(0), bytes, size.toULong()) + } + return out +} + +/** + * The controller actually on screen. + * + * Presenting from the root controller while a sheet is already up throws, and + * this app reaches the add-chapter form through navigation that may have put one + * there. Walking `presentedViewController` is the documented way to find the + * one that can present. + */ +private fun UIViewController.topmost(): UIViewController = + presentedViewController?.topmost() ?: this + +private const val TAG = "FilePicker" diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt new file mode 100644 index 00000000..e1e87242 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt @@ -0,0 +1,91 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.text.ChapterText +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.io.InputStream + +/** + * AWT's `FileDialog`, which is the native chooser on all three desktops. + * + * `JFileChooser` was the alternative and is worse here: it is Swing's own + * dialog, so it looks like Swing rather than like the host, and on macos it is + * notably not the file panel every other application shows. `FileDialog` maps + * to the platform's own in each case. + * + * **It blocks the calling thread**, which on desktop Compose is the UI thread, + * so showing it from a click handler freezes the window until it closes -- and + * reading the files afterwards would freeze it again. Both go to + * [Dispatchers.IO] and the result comes back through [onPicked] on the main + * dispatcher, which is the same shape the android launcher has by construction. + * + * The scope is the composition's. A dialog whose composable has left is a + * dialog whose result has nowhere to go, and cancelling the read is the right + * behaviour rather than a leak to avoid. + */ +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val scope = rememberCoroutineScope() + + return remember(scope, title, onPicked) { + FilePicker { multiple -> + scope.launch { + val picked = withContext(Dispatchers.IO) { chooseFiles(title, multiple) } + onPicked(picked) + } + } + } +} + +private fun chooseFiles(title: String, multiple: Boolean): List { + // A null parent is allowed and gives an unowned dialog. It is what a Compose + // desktop window gets us without reaching for the AWT window behind it, + // which `ComposeWindow` exposes but only from a `FrameWindowScope` that the + // app's navigation graph does not thread through. + val dialog = object : FileDialog(null as Frame?, title, LOAD) {} + dialog.isMultipleMode = multiple + dialog.isVisible = true + + val chosen = if (multiple) { + dialog.files?.toList().orEmpty() + } else { + listOfNotNull(dialog.directory?.let { dir -> dialog.file?.let { File(dir, it) } }) + } + + return chosen.mapNotNull(::readPickedFile) +} + +/** As on android: one byte past the cap, so an over-size file reads as over-size. */ +private fun readPickedFile(file: File): PickedFile? = + runCatching { + PickedFile( + name = file.name, + bytes = file.inputStream().use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) }, + ) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked file ${file.path}", error) + }.getOrNull() + +private fun InputStream.readAtMost(limit: Int): ByteArray { + val buffer = ByteArray(limit) + var filled = 0 + while (filled < limit) { + val read = read(buffer, filled, limit - filled) + if (read < 0) break + filled += read + } + return buffer.copyOf(filled) +} + +private const val TAG = "FilePicker" From dbc79406186bd8c5be02ed07d79ece59a396620a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:41:20 +0200 Subject: [PATCH 03/32] feat(frost): a signing proposal has a size, and the form says what it is Phase 2 of docs/chapter-uploads.md, and a fix to something wrong today. MAX_BATCH_SIZE bounds how many events a batch holds. Nothing bounded how large they are, and those are not the same limit: sixty-four dialects are a few kilobytes and sixty-four chunks of a chapter are eighty. The proposal is the largest message in this protocol by an order of magnitude -- a signer's nonce payload is k*133 bytes, their partial k*65, the proposal k whole events -- so a legal batch could exceed what a group event carries, and when it did the failure was silent: the proposal went out and the session stalled with nothing to blame. MAX_PROPOSAL_BYTES sits beside MAX_BATCH_SIZE and is enforced the same way: when proposing, against the real encoding, and independently in acceptProposal, before parsing. That second check is the one that matters, for the reason the count check already gives -- the proposal is where a remote party decides how much work everybody else does. The chapter form needed the same limit early enough to be useful, which the exact answer cannot be: it needs the chapter built and the chunks cut, one line from opening a session. So ChapterProposal estimates it. Measured over chapters from 1 KiB to 24 KiB and 1 to 63 chunks, the per-event cost of an encoded proposal runs 357-388 bytes; the estimate pays 448, because over-estimating costs an author a shorter chapter and under-estimating costs the group a stalled session. A test asserts it never falls under the real encoding and stays within a tenth of it. The plan asked for a flat MAX_ORIGINAL_TEXT_BYTES and the measurement says that is wrong by half: a 63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter about 30, because the envelopes are what varies. The chunk count is known as the text is typed, so the limit is too. While the screen was open, its last two hardcoded English literals moved to the catalogue, and the too-long message compares like with like -- what proposing would send against what a group message carries -- rather than a UTF-16 character count against a byte budget, which is what the first draft of it did. 944 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 6 + .../compose/managers/FrostSigningManager.kt | 47 ++++- .../compose/nostr/nip30303/ChapterProposal.kt | 94 +++++++++ .../press/mantra/compose/text/Kibibytes.kt | 24 +++ .../compose/ui/composable/AddChapterScreen.kt | 75 +++++-- .../ui/view/model/AddChapterViewModel.kt | 18 +- .../nostr/nip30303/ChapterProposalSizeTest.kt | 197 ++++++++++++++++++ docs/chapter-uploads.md | 87 +++++--- 8 files changed, 495 insertions(+), 53 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/Kibibytes.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f6e0cc25..161e370e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -495,4 +495,10 @@ Choose chapter files That file isn't text. A chapter is markdown or plain text. That file is too large to be a chapter. + This group has no shared key, so it cannot sign a chapter into existence. Run a shared key ceremony first. + %1$d chunks is more than the group can sign in one go. Split the chapter so that no part of it is over %2$d paragraphs. + This chapter is too long to propose. Asking the group to sign it and its %1$d chunks would send %2$s, and a group message carries %3$s. Split it in two. + %1$d words · %2$d characters · %3$s · %4$d of %5$d chunks + %1$s of %2$s + %1$s KiB 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 5dfa9ce4..59798fd1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -146,6 +146,29 @@ object FrostSigningManager { */ const val MAX_BATCH_SIZE: Int = 64 + /** + * The most JSON a proposal may carry. + * + * [MAX_BATCH_SIZE] bounds how many events a batch holds; this bounds how + * large they are, and the two are not the same limit. Sixty-four dialects + * are a few kilobytes and sixty-four chunks of a chapter are eighty, so a + * count cap alone lets a perfectly legal batch exceed what the transport + * carries -- and the proposal is the largest message in this protocol by an + * order of magnitude: a signer's nonce payload is `k * 133` bytes of hex and + * their partial `k * 65`, where the proposal carries `k` whole events. + * + * Deliberately the same number as `ChronicleEvent.MAX_PAGE_BYTES`, because + * it is the same question asked twice -- what one kind:445 clears once the + * MLS framing and the NIP-44 expansion outside it are paid for. The two are + * not shared as one constant because neither module should depend on the + * other for it, but they do have to be re-sized together, and whichever is + * raised first should say so here. + * + * Before this existed the failure was silent: the proposal went out, the + * group event was over size, and the session stalled with nothing to blame. + */ + const val MAX_PROPOSAL_BYTES: Int = 64 * 1024 + /** * Opens a signing session, making this device the coordinator. * @@ -270,6 +293,16 @@ object FrostSigningManager { "A signing session will sign at most $MAX_BATCH_SIZE events, not ${unsignedEvents.size}" } + // Encoded once, here, so the size check is the real encoding rather than + // an estimate of it -- and so the broadcast below sends the bytes that + // were measured. + val proposal = FrostSigningEvents.encodeProposal(unsignedEvents) + val proposalBytes = proposal.encodeToByteArray().size + + require(proposalBytes <= MAX_PROPOSAL_BYTES) { + "A signing proposal carries at most $MAX_PROPOSAL_BYTES bytes, not $proposalBytes" + } + val sessionId = RandomInstance.bytes(32).toHex() val session = FrostSigningSession( @@ -299,7 +332,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.PROPOSAL, - content = FrostSigningEvents.encodeProposal(unsignedEvents), + content = proposal, includeKey = true ) @@ -406,6 +439,18 @@ object FrostSigningManager { return null } + // Before parsing, not after. This and the count check below are the two + // places a remote party decides how much work this device does, and the + // cheaper of the two refusals should come first. + val proposalBytes = innerEvent.content.encodeToByteArray().size + if (proposalBytes > MAX_PROPOSAL_BYTES) { + logger.w( + "Signing proposal $sessionId carries $proposalBytes bytes, more than the " + + "$MAX_PROPOSAL_BYTES this device will accept; dropping" + ) + return null + } + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) if (proposed == null) { logger.w("Signing proposal $sessionId does not carry any events; dropping") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt new file mode 100644 index 00000000..0fb718c5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt @@ -0,0 +1,94 @@ +package press.mantra.compose.nostr.nip30303 + +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.text.Markdown + +/** + * What proposing a chapter will cost on the wire, close enough to say so while + * it is being typed. + * + * `FrostSigningManager.MAX_PROPOSAL_BYTES` is the limit and the manager enforces + * it exactly, by encoding the batch and measuring it. That answer arrives too + * late to be useful: by then the chapter has been built, the chunks cut and the + * session is one line from being opened, and the only thing left to do with the + * number is throw. This is the same question answered cheaply enough to put in + * front of somebody with a cursor still in the text field. + * + * **Why a chapter needs this at all.** A chapter travels with a chunk per + * paragraph, and the chapter carries the whole markdown while the chunks carry + * the same words again between them -- see `ChapterEvent.originalText` for why + * that duplication is wanted. So a proposal is a little over twice the prose, + * plus an envelope per event, and the limit arrives at about 20 KiB of markdown + * at the maximum chunk count. That is inside the range of a real book chapter. + * + * **Why this is not a constant.** A flat `MAX_ORIGINAL_TEXT_BYTES` was the + * obvious shape and is wrong by half. The envelopes are what varies: measured, + * a 63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter fits + * about 30. A single number safe for the first refuses a third of the second's + * text for no reason, and a single number sized for the second stalls sessions. + * The count is known as the text is typed, so the limit may as well be. + */ +object ChapterProposal { + + /** + * What one unsigned event costs beside its own text. + * + * **Measured, not derived.** Across chapters from 1 KiB to 24 KiB and from 1 + * to 63 chunks, the per-event cost of an encoded proposal runs between 357 + * and 388 bytes: a 64-character id, a 64-character pubkey, an empty `sig` + * (a proposal carries unsigned events, which is why this is smaller than a + * signed event's ~370-byte floor), the JSON structure, the alt tag, and -- on + * a chunk -- the 64-character chapter id it points at. + * + * Rounded up to 448 rather than to the measured maximum, and that direction + * is the deliberate one. Over-estimating costs an author a slightly shorter + * chapter; under-estimating costs the group a session that stalls with + * nothing to blame. `ChapterProposalSizeTest` asserts the estimate stays at + * or above the real encoding, so a tag added to either kind fails a test + * rather than a quorum. + */ + const val EVENT_OVERHEAD_BYTES: Int = 448 + + /** The limit, which is the manager's and not this file's to choose. */ + const val MAX_PROPOSAL_BYTES: Int = FrostSigningManager.MAX_PROPOSAL_BYTES + + /** + * What a proposal for this chapter would weigh. + * + * Takes the chunks rather than re-splitting, because every caller has just + * counted them for the other cap and because summing their text is exact + * where doubling the chapter's would not be -- the blank lines between + * paragraphs are in the chapter's text and in no chunk's. + */ + fun proposalBytes(originalText: String, chunks: List): Int = + originalText.utf8Size() + + chunks.sumOf { it.utf8Size() } + + (chunks.size + 1) * EVENT_OVERHEAD_BYTES + + fun proposalBytes(originalText: String): Int = + proposalBytes(originalText, Markdown.splitParagraphs(originalText)) + + fun fits(originalText: String, chunks: List): Boolean = + proposalBytes(originalText, chunks) <= MAX_PROPOSAL_BYTES + + /** + * The most markdown a chapter of this many chunks may carry. + * + * For the form, which has to say what the limit *is* and not only that it + * has been passed. Inverts [proposalBytes]: the envelopes come off the top + * and the prose is counted twice, so what is left is halved. + */ + fun maxOriginalTextBytes(chunkCount: Int): Int = + ((MAX_PROPOSAL_BYTES - (chunkCount + 1) * EVENT_OVERHEAD_BYTES) / 2) + .coerceAtLeast(0) +} + +/** + * The bytes a string takes as UTF-8, which is what the wire counts. + * + * Not `length`, which counts UTF-16 code units: the languages this app exists + * for are full of characters that are one unit and three bytes, so measuring a + * Setswana or Amharic chapter by its `length` would under-count it by half and + * the cap would stop binding exactly where it matters most. + */ +private fun String.utf8Size(): Int = encodeToByteArray().size diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Kibibytes.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Kibibytes.kt new file mode 100644 index 00000000..fdbea5a9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Kibibytes.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.text + +/** + * A byte count as the number part of "19.9 KiB". + * + * The unit itself is a string resource, so this returns only the figure. + * + * One decimal place, by integer arithmetic rather than through a float format. + * Two reasons, and the first is the one that matters: the figures this renders + * sit next to each other -- what a chapter weighs, and what the group can carry + * -- and whole kibibytes would print a 20,473-byte chapter and its 21,226-byte + * limit as the same "19 KiB", which reads as a form refusing something it says + * is within the limit. The second is that there is no common-code decimal + * formatter to reach for, and the three platforms' own would each punctuate it + * differently. + * + * Truncating rather than rounding, for the same reason: a figure shown as under + * the limit has to be under it. + */ +internal fun kibibytes(bytes: Int): String { + val whole = bytes / 1024 + val tenths = (bytes % 1024) * 10 / 1024 + return "$whole.$tenths" +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index 9465b790..98ee337a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -42,9 +42,11 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.files.rememberFilePicker +import press.mantra.compose.nostr.nip30303.ChapterProposal import press.mantra.compose.text.ChapterNaming import press.mantra.compose.text.ChapterText import press.mantra.compose.text.Markdown +import press.mantra.compose.text.kibibytes import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route @@ -64,6 +66,12 @@ import mantra.composeapp.generated.resources.propose_chapter import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter import mantra.composeapp.generated.resources.add_chapter_to import mantra.composeapp.generated.resources.choose_chapter_files +import mantra.composeapp.generated.resources.d_chunks_is_more_than_the_group_can_sign +import mantra.composeapp.generated.resources.s_kib +import mantra.composeapp.generated.resources.s_of_s +import mantra.composeapp.generated.resources.this_chapter_is_too_long_to_propose +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_for_a_chapter +import mantra.composeapp.generated.resources.words_characters_size_chunks import mantra.composeapp.generated.resources.load_from_file import mantra.composeapp.generated.resources.that_file_is_too_large import mantra.composeapp.generated.resources.that_file_isn_t_text @@ -160,7 +168,8 @@ fun AddChapterScreen( val originalText = originalTextFieldState.text.toString() val wordCount = Markdown.wordCount(originalText) val characterCount = Markdown.characterCount(originalText) - val paragraphCount = Markdown.splitParagraphs(originalText).size + val chunks = Markdown.splitParagraphs(originalText) + val paragraphCount = chunks.size // A chapter hangs off a version, and the group has to be able to // sign; without both there is nothing this screen can propose. @@ -172,10 +181,19 @@ fun AddChapterScreen( // is still a cursor in the text rather than after a failed propose. val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER + // The other cap, and the one that had been enforced nowhere: a + // proposal carries the chapter whole and every chunk cut from + // it, so it weighs a little over twice the prose plus an + // envelope per event. Past what a group event holds, the session + // used to go out and stall. + val proposalBytes = ChapterProposal.proposalBytes(originalText, chunks) + val tooLargeToPropose = !tooManyChunks && proposalBytes > ChapterProposal.MAX_PROPOSAL_BYTES + val canProposeChapter = artifactVersion != null && addChapterUIState.canSign && paragraphCount > 0 && - !tooManyChunks + !tooManyChunks && + !tooLargeToPropose // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. @@ -236,8 +254,7 @@ fun AddChapterScreen( ) { if (!addChapterUIState.canSign) { Text( - text = "This group has no shared key, so it cannot sign a " + - "chapter into existence. Run a shared key ceremony first.", + text = stringResource(Res.string.this_group_has_no_shared_key_for_a_chapter), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -249,9 +266,25 @@ fun AddChapterScreen( ) } else if (tooManyChunks) { Text( - text = "$paragraphCount chunks is more than the group can sign in " + - "one go. Split the chapter so that no part of it is over " + - "${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.", + text = stringResource( + Res.string.d_chunks_is_more_than_the_group_can_sign, + paragraphCount, + AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (tooLargeToPropose) { + Text( + text = stringResource( + Res.string.this_chapter_is_too_long_to_propose, + paragraphCount, + stringResource(Res.string.s_kib, kibibytes(proposalBytes)), + stringResource( + Res.string.s_kib, + kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES), + ), + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -287,14 +320,28 @@ fun AddChapterScreen( ) Text( - // The chunk count is what the group is asked to sign - // alongside the chapter, so it is a count of the work - // being proposed rather than a curiosity about the text. - text = "$wordCount words · $characterCount characters · " + - "$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " + - "of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}", + // Both caps, as the text is typed. The chunk count and + // the proposal's size are what the group is asked to + // carry, so they are a measure of the work being + // proposed rather than a curiosity about the text -- + // and either of them can be the one that binds. + text = stringResource( + Res.string.words_characters_size_chunks, + wordCount, + characterCount, + stringResource( + Res.string.s_of_s, + stringResource(Res.string.s_kib, kibibytes(proposalBytes)), + stringResource( + Res.string.s_kib, + kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES), + ), + ), + paragraphCount, + AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER, + ), style = MaterialTheme.typography.labelMedium, - color = if (tooManyChunks) { + color = if (tooManyChunks || tooLargeToPropose) { MaterialTheme.colorScheme.error } else { MaterialTheme.typography.labelMedium.color diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index bbe3e9d7..a7dc3e4a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -21,6 +21,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.TranslationScaffold import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChapterProposal import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository @@ -102,10 +103,19 @@ class AddChapterViewModel( val originalText = originalTextField.text.toString() val paragraphs = Markdown.splitParagraphs(originalText) - // The cap is the group's, not this screen's, and proposing past it - // throws rather than failing softly. The form says so as it is typed; - // this is the check that has to hold when it does not. - if (name.isBlank() || paragraphs.isEmpty() || paragraphs.size > MAX_CHUNKS_PER_CHAPTER) { + // Both caps are the group's, not this screen's, and proposing past + // either throws rather than failing softly. The form says so as it is + // typed; these are the checks that have to hold when it does not. + // + // Two caps rather than one because they bound different things: how many + // events a session signs, and how many bytes the proposal carrying them + // weighs. A chapter of sixty-three one-word paragraphs passes the second + // and fails the first; one of three very long ones does the reverse. + if (name.isBlank() || + paragraphs.isEmpty() || + paragraphs.size > MAX_CHUNKS_PER_CHAPTER || + !ChapterProposal.fits(originalText, paragraphs) + ) { onFailure.invoke() return } 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 new file mode 100644 index 00000000..dc9c477e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt @@ -0,0 +1,197 @@ +package press.mantra.compose.nostr.nip30303 + +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 kotlin.test.assertTrue +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.text.Markdown + +/** + * [ChapterProposal] estimates what the manager measures, and the only property + * worth asserting is that it never estimates low. + * + * The form reads the estimate as the text is typed; `proposeSigningBatch` + * encodes the batch and measures it for real. If the estimate were ever under + * the truth, the form would offer a chapter that the manager then refuses with + * an `IllegalArgumentException` -- or, before that check existed, that went out + * and stalled the session with nothing to blame. + * + * So these tests build the same events the manager would and compare. A tag + * added to either kind fails here rather than in a room. + */ +class ChapterProposalSizeTest { + private val groupKey = "a".repeat(64) + private val artifactVersionId = "b".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 batch as `proposeSigningBatch` would build it: the chapter, then its chunks. */ + private fun batchFor(originalText: String, name: String = "Chapter 1"): List { + val chapter = unsigned( + ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = name, + originalText = originalText, + index = 0, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + createdAt = 1_700_000_000, + ) + ) + return listOf(chapter) + ChunkEvent.splitOf(chapter).map(::unsigned) + } + + private fun encodedBytes(originalText: String): Int = + FrostSigningEvents.encodeProposal(batchFor(originalText)).encodeToByteArray().size + + /** Markdown of roughly [bytes] bytes, in [paragraphs] paragraphs. */ + private fun markdownOf(bytes: Int, paragraphs: Int): String { + val perParagraph = (bytes / paragraphs).coerceAtLeast(4) + return (0 until paragraphs).joinToString("\n\n") { "w ".repeat(perParagraph / 2).trim() } + } + + @Test + fun `the estimate is never under the real encoding`() { + // The grid the overhead constant was measured over. Both ends matter: at + // one chunk the text is counted twice exactly and the per-event cost is + // at its highest, which is where an estimate is most likely to fall + // short; at sixty-three the envelopes dominate. + val shapes = listOf(1, 2, 5, 10, 20, 40, 63) + val sizes = listOf(1, 4, 8, 16, 20, 24) + + for (paragraphs in shapes) { + for (kib in sizes) { + val text = markdownOf(kib * 1024, paragraphs) + val chunks = Markdown.splitParagraphs(text) + val estimated = ChapterProposal.proposalBytes(text, chunks) + val actual = encodedBytes(text) + + assertTrue( + estimated >= actual, + "estimate $estimated under the real $actual for $paragraphs " + + "paragraphs of ${kib} KiB", + ) + } + } + } + + @Test + fun `the estimate is close enough to be worth showing`() { + // Not merely conservative -- useful. An estimate twice the truth would + // pass the test above and refuse half of every legal chapter. + val text = markdownOf(16 * 1024, 40) + val chunks = Markdown.splitParagraphs(text) + + val estimated = ChapterProposal.proposalBytes(text, chunks) + val actual = encodedBytes(text) + + assertTrue( + estimated <= actual + actual / 10, + "estimate $estimated is more than a tenth over the real $actual", + ) + } + + @Test + fun `a chapter the estimate accepts really does encode under the cap`() { + // The property the form's offer rests on, stated as a round trip rather + // than as arithmetic: find the longest text the estimate allows at the + // worst chunk count, and check the manager would carry it. + val chunkCount = AddChapterMaxChunks + val allowance = ChapterProposal.maxOriginalTextBytes(chunkCount) + val text = markdownOf(allowance, chunkCount) + val chunks = Markdown.splitParagraphs(text) + + assertEquals(chunkCount, chunks.size) + assertTrue(ChapterProposal.fits(text, chunks)) + assertTrue( + encodedBytes(text) <= FrostSigningManager.MAX_PROPOSAL_BYTES, + "a chapter the form accepts encoded to ${encodedBytes(text)} bytes, over the cap", + ) + } + + @Test + fun `the cap binds inside the range of a real chapter`() { + // Why any of this exists. Before the check, a chapter of this size was + // offered by the form, proposed, and stalled. 28 KiB over forty + // paragraphs is a long chapter of a real book, not a pathological one -- + // and measured, the fit runs out at about 24 KiB for that shape. + val text = markdownOf(28 * 1024, 40) + val chunks = Markdown.splitParagraphs(text) + + assertTrue( + encodedBytes(text) > FrostSigningManager.MAX_PROPOSAL_BYTES, + "expected ${encodedBytes(text)} bytes to exceed the cap", + ) + assertTrue(!ChapterProposal.fits(text, chunks)) + } + + @Test + 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 + // shape; the figure is the largest whole KiB of markdown that encodes + // under the cap. + val fitting = (1..64).last { kib -> + encodedBytes(markdownOf(kib * 1024, 40)) <= FrostSigningManager.MAX_PROPOSAL_BYTES + } + + assertEquals(24, fitting) + } + + @Test + fun `an ordinary chapter is nowhere near either cap`() { + // The other half of the claim: this is a limit on very long chapters, + // not a tax on normal ones. Twelve paragraphs of a few hundred words. + val text = markdownOf(8 * 1024, 12) + val chunks = Markdown.splitParagraphs(text) + + assertTrue(ChapterProposal.fits(text, chunks)) + assertTrue(encodedBytes(text) < FrostSigningManager.MAX_PROPOSAL_BYTES / 2) + } + + @Test + 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 + // where it matters most. + val ascii = "w ".repeat(512).trim() + val amharic = "ሰ ".repeat(512).trim() + + assertEquals(ascii.length, amharic.length) + assertTrue( + ChapterProposal.proposalBytes(amharic, listOf(amharic)) > + ChapterProposal.proposalBytes(ascii, listOf(ascii)), + ) + } + + /** + * Named here rather than imported from the view model, so this file depends + * on the protocol and not on a screen. Asserted equal below. + */ + 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`() { + assertEquals(FrostSigningManager.MAX_BATCH_SIZE - 1, AddChapterMaxChunks) + } +} diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index 0ad6c375..d5aefa5a 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -289,36 +289,50 @@ title; `03 - The Wreck.md` is. **Half a day. A fix to the path that exists, and a prerequisite for Phase 4.** -A chapter whose markdown is over roughly 28 KiB cannot be proposed today and +A chapter whose markdown is over roughly 24 KiB cannot be proposed today and nothing says so. Make the limit explicit and check it where the paragraph cap is already checked. -```kotlin -// ChapterEvent companion, beside build() -/** - * The most markdown a chapter may carry. - * - * A chapter is proposed with a chunk per paragraph, so the proposal carries its - * text roughly twice -- whole in the chapter's `original_text` tag, and again - * split across the chunks -- before any framing. The group event that carries - * the proposal is sized by ChronicleEvent.MAX_PAGE_BYTES; half of it, less room - * for k event envelopes, is what is left for prose. - * - * Measure it against a real proposal rather than trusting this arithmetic, the - * way MAX_PAGE_BYTES itself says to. - */ -const val MAX_ORIGINAL_TEXT_BYTES = 24 * 1024 -``` +**Built differently, in three ways, and the measurement is why.** Encoding real +proposals gave a per-event overhead of 357–388 bytes across chapters from 1 KiB +to 24 KiB and from 1 to 63 chunks — the plan's ~370 estimate, arrived at for the +wrong reason: a proposal carries *unsigned* events, so there is no 128-character +signature in it, and the figure is instead an id, a pubkey, an empty `sig`, the +JSON structure and, on a chunk, the 64-character chapter id it points at. -24 KiB rather than 28: the envelopes are not free (an id, a pubkey and a -signature are ~370 bytes before an event says anything, times `k`), and a cap -that is comfortably under the wire limit fails in the form instead of on the -wire. It should be arrived at by measuring an encoded proposal for a 63-chunk -chapter, not by this paragraph. +What that measurement changed: -**And the number is provisional until Phase 8 lands.** Block splitting produces -more chunks than blank-line splitting, so the same prose carries more envelopes; -whichever of the two phases goes second re-runs the measurement. +**One. A flat `MAX_ORIGINAL_TEXT_BYTES` is wrong by half.** Measured, a +63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter fits about +30, because the envelopes are what varies. A single constant safe for the first +refuses a third of the second's text for nothing; one sized for the second +stalls sessions. The chunk count is known as the text is typed, so the limit may +as well be — `ChapterProposal.proposalBytes(text, chunks)` and +`maxOriginalTextBytes(chunkCount)` instead of a constant. + +**Two. The limit belongs to the manager, not to the chapter.** +`FrostSigningManager.MAX_PROPOSAL_BYTES = 64 * 1024`, beside `MAX_BATCH_SIZE`, +enforced in `proposeSigningBatch` against the real encoding and *independently* +in `acceptProposal` — before parsing, since that is the cheaper refusal. The +count cap already had exactly this two-sided treatment for exactly this reason: +the proposal is where a remote party decides how much work everybody else does, +and until now its *size* was unbounded. Both propose forms funnel into the +`lead`/`dependents` one, so a single check covers them. + +**Three. `ChapterProposal` estimates what the manager measures.** The exact +answer needs the chapter built and the chunks cut, which is one line from +opening a session — too late to tell somebody with a cursor in a text field. +So: a cheap estimate for the form at 448 bytes per event, rounded up from the +measured 388, and a test asserting the estimate never falls under the real +encoding. Over-estimating costs an author a slightly shorter chapter; +under-estimating costs the group a stalled session, so the rounding goes that +way on purpose. + +**The number is still provisional until Phase 8 lands.** Block splitting +produces more chunks than blank-line splitting, so the same prose carries more +envelopes; `ChapterProposalSizeTest` pins where the fit runs out (24 KiB over +forty paragraphs), so that phase will move a pinned figure rather than quietly +narrow the limit. Where it goes: @@ -326,8 +340,11 @@ Where it goes: `paragraphs.size > MAX_CHUNKS_PER_CHAPTER` guard, same early `onFailure`. - `AddChapterScreen` — the counter line already reads `"$wordCount words · $characterCount characters · $paragraphCount chunks of N"`; - it gains the byte figure against the cap and turns `error`-coloured on the - same rule the chunk count does. + it gains the proposal's size against the cap and turns `error`-coloured on the + same rule the chunk count does. The message compares like with like — what + proposing would send, against what a group message carries — rather than a + character count against a byte budget, which was the first draft of it and was + comparing UTF-16 units to bytes. - The two existing hardcoded English literals on that screen (the no-shared-key message and the too-many-chunks message) move into `composeApp/src/commonMain/composeResources/values/strings.xml` while the file @@ -335,13 +352,15 @@ Where it goes: title-case strings with literals reported; a third one added by this work would be the wrong direction. -**Test:** extend `ChapterBatchProposalJvmTest`, which already asserts the -paragraph cap's boundary (`MAX_BATCH_SIZE - 1` proposes, `MAX_BATCH_SIZE` -throws). Add the byte boundary the same way: one chapter at the cap proposes, -one a byte over does not. And one that measures the *encoded proposal* for a -cap-sized, 63-chunk chapter and asserts it is under `MAX_PAGE_BYTES` — that is -the assertion that makes the constant honest, and the only one that will catch -it drifting when a tag is added to either kind. +**Tests:** a `ChapterProposalSizeTest` in `commonTest` — pure, since the +estimate and the encoding are both pure — over the grid the overhead was +measured on, asserting the estimate never falls under the real encoding and +stays within a tenth of it. Plus the round trip the form's offer rests on: the +longest text the estimate allows at the worst chunk count really does encode +under the cap. Plus the boundary pinned as a number, so a tag added to either +kind moves a figure in a test rather than producing a quorum that never +arrives. And the byte-vs-character one, in Amharic, because an ASCII-only test +suite would never have caught the UTF-16 slip above. --- From 83bd91f46c33ee43f8df750b618cc7cdda6c18ae Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:46:09 +0200 Subject: [PATCH 04/32] fix(chapters): number a chapter past the ones already proposed Phase 3 of docs/chapter-uploads.md, and the second fix to something wrong today. A chapter's index is signed into the event and cannot be recounted on arrival: a device applying one cannot recount from a version other members may have added to in a different order. So the number has to be right when the chapter is proposed, and `getChaptersForArtifactVersion(...).size` is not it -- a proposed chapter is not a row until a quorum signs it, so two proposals a second apart read the same table and are signed at the same index. At one chapter at a time that is a race nobody hits. An upload of twelve files hits it eleven times out of eleven. The information was already published. A session's items carry the unsigned events it is signing, and an unsigned event's fields are settled at proposal time -- which is the whole reason FrostSigningRepository.unsignedEvents exists rather than only signedEvents. So ChapterIndexing.nextIndex reads the table and the room's proposals, and returns one past the highest rather than a count: a count is wrong the moment the sequence has a gap, and a chapter that failed to reach a quorum leaves one. Three things that are easy to get wrong and are tested. Every session counts whatever stage it is at, because filtering would trade a harmless gap for a collision with a stalled session that still completes. Items are filtered by kind, because a chunk carries an index too -- its position in the chapter -- and reading those as chapter indices puts the second chapter of a two-paragraph first chapter at 2. And proposals are scoped by the artifactVersionId tag, because the row query is scoped by version while observeSessionsForChatRoom returns the whole room. This fixes one device, not the group: two members proposing at the same moment still collide, and no amount of local reading will change that. That is tolerable where this was not -- an index orders reading, two chapters at index 4 sort arbitrarily between themselves, and nothing else breaks. 951 tests, no failures. Co-Authored-By: Claude Opus 5 --- .../compose/managers/ChapterIndexing.kt | 114 +++++++ .../ui/view/model/AddChapterViewModel.kt | 13 +- .../managers/ChapterIndexingJvmTest.kt | 305 ++++++++++++++++++ docs/chapter-uploads.md | 53 +-- 4 files changed, 464 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterIndexing.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterIndexingJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterIndexing.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterIndexing.kt new file mode 100644 index 00000000..425da261 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterIndexing.kt @@ -0,0 +1,114 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.first +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository + +/** + * Where the next chapter of a version goes. + * + * The index is signed into the chapter and cannot be recounted on arrival -- a + * device applying one cannot recount from a version other members may have + * added to in a different order -- so the number has to be right at proposal + * time. "How many chapters are in the table" is not it. + * + * **A proposed chapter is not a row.** It becomes one when a quorum signs, + * which takes as long as it takes, and until then nothing in + * `MantraChapter` knows about it. So reading the table gives the same answer to + * two proposals made a second apart, and both get signed at the same index. + * At one chapter at a time that is a race nobody hits; an upload of twelve + * files hits it eleven times out of eleven. + * + * The information needed is already published. A session's items carry the + * unsigned events it is signing, and an unsigned event's id and fields are + * settled at proposal time -- that is the whole reason + * [FrostSigningRepository.unsignedEvents] exists rather than only + * `signedEvents`. So the answer is the table *and* the room's open proposals. + */ +object ChapterIndexing { + + private const val TAG = "ChapterIndexing" + + /** + * The index for the next chapter of [artifactVersionId], counting chapters + * this room has proposed but not yet signed. + * + * **One past the highest, not a count.** A count is wrong the moment the + * sequence has a gap, and gaps are expected here: a chapter that fails to + * reach a quorum never becomes a row, and its index is spent -- a + * re-proposed chapter is a different event. Ordering survives a gap, so + * leaving one is the cheap answer and renumbering would be the expensive + * wrong one. + * + * **Every session counts, whatever stage it is at.** A failed session's + * index is wasted, which costs a gap; a stalled session's chapter may still + * arrive, and stepping over it costs nothing. Filtering by stage would + * trade a harmless gap for a possible collision. + * + * **This fixes one device, not the group.** Two members proposing at the + * same moment still collide: neither sees the other's proposal until it + * arrives, and no amount of local reading will change that. That is + * tolerable where this is not -- an index orders reading, two chapters at + * index 4 sort arbitrarily between themselves, and nothing else breaks. The + * local case is worth fixing because an upload makes it certain rather than + * unlikely. + * + * Failing soft on purpose: if the proposals cannot be read, the table's + * answer is still better than nothing, and refusing to propose a chapter + * because its number could not be checked would be the wrong trade. + */ + suspend fun nextIndex( + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + chatRoomId: String, + artifactVersionId: String, + ): Int { + val signed = mantraRepository + .getChaptersForArtifactVersion(artifactVersionId) + .maxOfOrNull { it.index } + + val proposed = runCatching { + proposedIndices(frostSigningRepository, chatRoomId, artifactVersionId).maxOrNull() + }.onFailure { error -> + Logger.withTag(TAG).e( + "Could not read this room's proposals; numbering the next chapter from the " + + "table alone, which may collide with a chapter already proposed", + error, + ) + }.getOrNull() + + val highest = listOfNotNull(signed, proposed).maxOrNull() ?: return 0 + return highest + 1 + } + + /** + * The indices of chapters this room has proposed for [artifactVersionId]. + * + * Read off the unsigned events rather than from anything stored about them, + * because there is nothing stored about them: a proposal's items are its + * only record until it is signed. Non-chapter items -- a dialect, a + * translation, the chunks beside a chapter -- are ignored by kind, and a + * chapter belonging to another version of the same artifact by its + * `artifactVersionId` tag. + */ + private suspend fun proposedIndices( + frostSigningRepository: FrostSigningRepository, + chatRoomId: String, + artifactVersionId: String, + ): List = + frostSigningRepository.observeSessionsForChatRoom(chatRoomId).first() + .flatMap { session -> frostSigningRepository.unsignedEvents(session.items) } + .filter { event -> event.kind == ChapterEvent.KIND } + .mapNotNull { event -> + ChapterEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ).takeIf { it.artifactVersionId() == artifactVersionId }?.index() + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index a7dc3e4a..fc529e3f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.ChapterIndexing import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.TranslationScaffold import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -131,7 +132,17 @@ class AddChapterViewModel( // may have added to in a different order. The scaffolding proposed // below is placed at the same one, so a translation chapter agrees // with the chapter it stands for. - val chapterIndex = mantraRepository.getChaptersForArtifactVersion(artifactVersion.id).size + // + // Counting the room's open proposals as well as its rows, because a + // chapter proposed a second ago is not a row yet and reading the + // table alone would hand it this chapter's number -- see + // [ChapterIndexing.nextIndex]. + val chapterIndex = ChapterIndexing.nextIndex( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + chatRoomId = chatRoomId, + artifactVersionId = artifactVersion.id, + ) val chapterEventTemplate = ChapterEvent.build( artifactVersionId = artifactVersion.id, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterIndexingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterIndexingJvmTest.kt new file mode 100644 index 00000000..673db3e7 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterIndexingJvmTest.kt @@ -0,0 +1,305 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +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.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraChapter +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.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown + +/** + * A chapter's index has to be right when it is proposed, because it is signed + * into the event and cannot be recounted on arrival. + * + * The test that matters here is the third one, and it fails against the old + * `getChaptersForArtifactVersion(...).size`: three chapters proposed back to + * back all read the same empty table and all get index 0. At one chapter at a + * time that is a race nobody hits. An upload of twelve files hits it every + * time, which is why this is a fix rather than a feature. + */ +class ChapterIndexingJvmTest { + private val participants = 3 + private val threshold = 2 + + 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 val dialectId = "d".repeat(64) + private val artifactId = "a1".padEnd(64, '0') + private val artifactVersionId = "b".repeat(64) + private val otherVersionId = "c".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + private lateinit var mantraRepository: MantraRepository + private lateinit var frostSigningRepository: FrostSigningRepository + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + mantraRepository = DatabaseMantraRepository(db, scope) + frostSigningRepository = DatabaseFrostSigningRepository(db, scope) + + 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], + ) + ) + } + + // The artifact a chapter hangs off, and a second version of it, so that + // "chapters of this version" can be told from "chapters in this room". + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = "", + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://example.test/mockingbird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = "", + ) + ) + listOf(artifactVersionId to "1.0", otherVersionId to "2.0").forEach { (id, label) -> + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = id, + artifactId = artifactId, + publicKey = roomId, + versionLabel = label, + chatRoomId = roomId, + signature = "", + ) + ) + } + + room = LocalChatRoom(chatRoom = chatRoom) + } + + private suspend fun insertChapter(index: Int, versionId: String = artifactVersionId) { + db.mantraChapterDao().upsert( + MantraChapter( + id = "${versionId.take(8)}chapter$index".padEnd(64, '0'), + artifactVersionId = versionId, + publicKey = roomId, + name = "Chapter $index", + originalText = "Signed already.", + index = index, + wordCount = 2, + characterCount = 15, + signature = "", + chatRoomId = roomId, + ) + ) + } + + /** Exactly what `AddChapterViewModel.addChapter` proposes, at a given index. */ + private suspend fun proposeChapterAt(index: Int, versionId: String = artifactVersionId) { + val originalText = "Paragraph one.\n\nParagraph two." + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = ChapterEvent.build( + artifactVersionId = versionId, + name = "Chapter $index", + originalText = originalText, + index = index, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ), + dependents = ChunkEvent::splitOf, + ) + } + + private suspend fun nextIndex(versionId: String = artifactVersionId): Int = + ChapterIndexing.nextIndex( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + chatRoomId = roomId, + artifactVersionId = versionId, + ) + + @Test + fun `an empty version starts at zero`() = runBlocking { + openDevice() + + assertEquals(0, nextIndex()) + } + + @Test + fun `signed chapters are counted`() = runBlocking { + openDevice() + insertChapter(0) + insertChapter(1) + + assertEquals(2, nextIndex()) + } + + @Test + fun `three chapters proposed back to back take three different indices`() = runBlocking { + openDevice() + + // The whole point. Nothing is signed, so the table is empty throughout + // and the old `.size` answered 0 three times. + val allocated = (1..3).map { + val index = nextIndex() + proposeChapterAt(index) + index + } + + assertEquals(listOf(0, 1, 2), allocated) + } + + @Test + fun `a proposal counts alongside the rows`() = runBlocking { + openDevice() + insertChapter(0) + proposeChapterAt(1) + + assertEquals(2, nextIndex()) + } + + @Test + fun `one past the highest, not a count, so a gap is stepped over`() = runBlocking { + openDevice() + insertChapter(0) + insertChapter(5) + + // A chapter that failed to reach a quorum spent its index: a re-proposed + // chapter is a different event. Counting rows would hand out 2 and + // collide with nothing, but it would also renumber a version whose + // chapters are 0 and 5 into one whose next chapter is 2. + assertEquals(6, nextIndex()) + } + + @Test + fun `another version's chapters are not counted`() = runBlocking { + openDevice() + insertChapter(0, otherVersionId) + insertChapter(1, otherVersionId) + proposeChapterAt(7, otherVersionId) + + // Rows are already scoped by the query; proposals are not -- every + // session in the room comes back together -- so the version tag is what + // separates them, and getting it wrong would push every version's + // numbering up by the whole room's. + assertEquals(0, nextIndex()) + assertEquals(8, nextIndex(otherVersionId)) + } + + @Test + fun `the chunks beside a chapter are not mistaken for chapters`() = runBlocking { + openDevice() + + // A chapter proposal is a batch: one chapter and a chunk per paragraph. + // A chunk carries an index too -- its position in the chapter -- and + // reading those as chapter indices would have the second chapter of a + // two-paragraph first chapter land at 2 instead of 1. + proposeChapterAt(0) + + assertEquals(1, nextIndex()) + } +} diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index d5aefa5a..ec61cabe 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -380,25 +380,36 @@ and the last of these exists precisely for this: "A caller wanting an id … nee it while the first session is still running, and the id is settled at proposal time." -```kotlin -// MantraRepository, or a small ChapterIndexing object beside TranslationScaffold -/** - * The index the next chapter of [artifactVersionId] takes, counting chapters - * the room has proposed but not yet signed. - * - * Rows alone are wrong by however many proposals are in flight, which at N = 1 - * is almost never and during an upload is always. A proposed chapter's index is - * already fixed -- it is signed into the unsigned event the session will sign -- - * so it counts. - */ -suspend fun nextChapterIndex(chatRoomId: String, artifactVersionId: String): Int -``` +**Built as `ChapterIndexing.nextIndex`**, an object beside `TranslationScaffold` +rather than a method on `MantraRepository`, because it needs both repositories — +the table for what is signed and the signing repository for what is proposed — +and a repository method that reaches into another repository is the wrong shape. +It takes both, which also makes it testable without a view model. Implemented as: rows for the version, plus the unsigned `ChapterEvent`s of every -open session in the room whose `artifactVersionId()` matches, de-duplicated by -event id, and `maxOf(index) + 1` rather than a count — a count is wrong the -moment a gap exists, and after a chapter fails to reach quorum a gap is exactly -what there is. +session in the room whose `artifactVersionId()` matches, and `maxOf(index) + 1` +rather than a count — a count is wrong the moment a gap exists, and after a +chapter fails to reach quorum a gap is exactly what there is. + +Three details the build settled that the plan left open: + +- **Every session counts, whatever stage it is at.** Filtering to open ones + would trade a harmless gap (a failed session's index is spent, since a + re-proposed chapter is a different event) for a possible collision with a + stalled session that still completes. +- **Items are filtered by kind, which is not optional.** A chapter proposal is a + batch, and a chunk carries an `index` tag too — its position in the chapter. + Reading those as chapter indices would put the second chapter of a + two-paragraph first chapter at 2. There is a test for exactly that. +- **Proposals are not scoped by version and rows are.** The row query takes an + `artifactVersionId`; `observeSessionsForChatRoom` returns the whole room, so + the `artifactVersionId` tag is what separates one version's proposals from + another's. Getting it wrong would push every version's numbering up by the + whole room's. + +Failing soft on purpose: if the proposals cannot be read, the table's answer is +still better than nothing, and refusing to propose a chapter because its number +could not be checked would be the wrong trade. Then `AddChapterViewModel` calls it instead of `.size`, and the upload allocates `base + i` for its i-th file **once**, before proposing anything, rather than @@ -412,9 +423,11 @@ are tolerable: the index orders reading, two chapters at index 4 sort arbitrarily between themselves and nothing else breaks. Worth a sentence in the code so the next reader does not take the local fix for a global one. -**Test** (`jvmTest`, beside `ChapterBatchProposalJvmTest`): propose three -chapters back to back without completing any session, and assert the three -signed indices are 0, 1, 2 — which is the test that fails today. +**Tests** (`ChapterIndexingJvmTest`, beside `ChapterBatchProposalJvmTest`): +propose three chapters back to back without completing any session, and assert +the three indices are 0, 1, 2 — which is the test that fails today, since +nothing is signed so the table answers 0 three times. Plus the gap, the two +versions, and the chunk-vs-chapter confusion above. --- From de4f15794a472a550dbd226176c64ed55b2754c5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:55:23 +0200 Subject: [PATCH 05/32] docs: a phased plan for projects, and the four keys it has to re-answer The platform's server schema has had `project` and its two join models since before this app existed. Bringing them here is not a port: the server answers "whose project is this" out of a table of accounts, and this app is a group with no accounts, no server and no authority but a quorum of its own members. So every foreign key on `project` is re-answered. The creator becomes a `creator` tag and the owners a set of `p` tags with an owner role -- written into the template before it is proposed, so they are inside the id the quorum signs rather than one device's note about who inserted a row. The owner of record is still structurally the room, and `chatRoomId` carries that; the tags are what let a project be the group's work and somebody else's property. Campaign is out of scope entirely. Two things the plan is mostly about, because neither is recoverable from the code. The nip30303 kind band is not merely full -- 30310, 30311 and 30312 are double-booked with the ceremony family, survivable only because none of the three nip30303 kinds has a producer -- so projects take 30330-30332 and the collision gets written down. And `ContributorTag.parse` drops a role that is not in `KNOWN_ROLES`: the tag parses, the role comes back null, the string stays inside the signed event id, the event still verifies, and the project lands with no owners. Nothing is logged. `ROLE_OWNER` reaching that set is Phase 1's first line, and three tests point at it. Ten phases, 0-7 to build and 8-9 deferred. The one decision to make before Phase 1 is the scope fork: a project's contents are same-room, with real foreign keys, because the FK is what makes a bad proposal fail loudly. Cross-room is Phase 9 and follows `parentChatRoomId`'s precedent -- a plain column, never a foreign key, because a dangling value is the normal state. Co-Authored-By: Claude Opus 5 --- docs/README.md | 8 + docs/project-feature.md | 1038 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1046 insertions(+) create mode 100644 docs/project-feature.md diff --git a/docs/README.md b/docs/README.md index 933e8da5..0e94825d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ silent, or a decision that looked arbitrary and was not. | [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 | | [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 | +| [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 | | [curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md) | the second pull: the fork's several-profiles line, ten commits and its first schema migration — which of it is a fix Mantra has today, who allocates a schema version number when two trees share one history, and the one conflict, which was Mantra's own | @@ -72,3 +73,10 @@ smallest of the plans: it changes the entrance to the direct-message flow and nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and `ChatRoomMessagingViewModel.initiateNewChat` open, its four decisions before its four phases, and its table of where the build chose differently first. +The projects note is a phased plan that has not been built, and is the only one +whose starting point is outside this repository: the platform's server schema, +`mantra-db-models`, has had the three models for years. It reads as the +translation domain's unanswered question — the app can say what a chapter is and +who translated it, and cannot say what any of it is for — so read it after the +nip30303 conventions are familiar, its decisions before its phases, and its scope +fork before either, because that one changes the shape of the rest. diff --git a/docs/project-feature.md b/docs/project-feature.md new file mode 100644 index 00000000..07387781 --- /dev/null +++ b/docs/project-feature.md @@ -0,0 +1,1038 @@ +# Projects + +A phased plan, not yet built. + +`mantra-db-models` — the platform's server-side Sequelize schema — has had three +models waiting for this app since before it existed: `project`, with a name and a +description, and the two join models that give it its contents, +`projectArtifactVersion` and `projectTranslationArtifactVersion`. A project is +how the platform says *this body of work*: a set of source versions to translate +from and a set of translations being produced against them, grouped under one +name so that funding, progress and membership have something to attach to. + +This app has the whole tree underneath a project already — eight nip30303 kinds +from `Artifact` (30300) down to `TranslationChunk` (30309), each one an event the +group signs with its own threshold key and a Room row derived from it. What it +has no way to say is that two of those translations and three of those source +versions are one piece of work. + +The server's schema is a relational description of a platform with accounts. This +app is a Marmot/MLS group with no accounts, no server and no authority but a +quorum of its own members. Every foreign key on `project` therefore has to be +re-answered, and saying how is most of the work this document does: one becomes a +tag of its own, one becomes a set of tags, one becomes the room, and one is out of +scope. The code to write is comparatively mechanical — the same eight-file shape +every nip30303 entity already has, three times over — and the one place it goes +quietly wrong is a role the parser has not been told about. + +## What the server's schema says, and what survives + +``` +project + id string, nanoid + name string, not null + description text, not null + creatorId -> user, not null + ownerId -> owner, not null, UNIQUE + campaign hasOne campaign + hasMany projectArtifactVersion + hasMany projectTranslationArtifactVersion + +projectArtifactVersion projectTranslationArtifactVersion + id id + creatorId -> user, NN creatorId -> user, NN + projectId -> project, NN projectId -> project, NN + artifactVersionId + -> artifactVersion, NN -> translationArtifactVersion, NN +``` + +Taking those fields one at a time: + +**`name` and `description` survive** as the substance of the thing. By this +family's convention the long text goes in the event's `content` and the short +label gets a tag of its own — `ArtifactEvent` puts the name in content because an +artifact has no longer field, `ChapterEvent` puts `originalText` there and the +name in a `NameTag`. A project has both, so it follows the chapter: `content` is +the description, and `NameTag` carries the name. + +**`creatorId` survives as a `CreatorTag` on the project event**, and the +difference between that and a column is the whole point. Every nip30303 entity a +member creates is now proposed for FROST signing rather than submitted, so the row +that lands is authored by *the room's own key* and carries no trace of who typed +it: `publicKey` is the room's id, and both Marmot columns are null on a +group-signed row. A `creatorPublicKey` column filled in from local knowledge would +be a field the signed event does not carry — which, under a signature, is the one +kind of mistake that is expensive. A `["creator", ""]` tag written into +the template *before* it is proposed is not that: it is inside the id the quorum +signs, so what lands is the group saying "this project was started by X" rather +than one device's note about who happened to insert a row. + +That is a stronger statement than the server's column, and it is worth being clear +about what kind of statement it is. The creator does not sign the project event — +the room does — so this is a **group attestation, not proof of authorship**. A +member could propose a project crediting somebody else, and a quorum that does not +read the proposal would sign it. The mitigation is not cryptographic and does not +need to be: it is that the signing screen must show the creator, which makes it one +of the things a member is agreeing to. See Phase 4. + +The proposer's identity is still recorded independently, on the +`FrostSigningSession` the proposal opened. The two can disagree, and when they do +the session is the fact about who acted while the tag is the group's claim about +whose work it is. Those are different questions and both are worth keeping. + +**`ownerId` survives as `ContributorTag`s with an owner role — plural**, and it +buys something the structural answer could not. The structural answer is real and +stays: a project is signed by the group's threshold key at the room's derivation +path, so it says which *group* it belongs to by being signed at all, and +`chatRoomId` — a foreign key to `ChatRoom` with `onDelete = CASCADE` — carries that +exactly as on `MantraArtifact` and `MantraDialect`. What it cannot say is that a +project is the group's work and somebody *else's* property, which is the whole +reason the server has an `owner` with an `ownerEntity` join behind it: an owner +there can be an individual or an organization, and need not be the people doing +the work. + +Pubkeys collapse that chain, because in this app an owner that is not a person is +a group, and a group is a pubkey — its room id. **A project can have several**, so +this is a set rather than a field: two organizations co-owning a translation, or a +publisher and the group that did the work, are ordinary cases, and the server's +`UNIQUE` on `project.ownerId` — one owner, one project, which looks like a +modelling slip on that side — is dropped rather than reproduced. + +**Always emit at least one, and default to the room's own public key.** Not an +empty set with the room as an implied default: a project with no owner tag and a +project owned by the room would then be the same project, and making them the same +*event* means no reader ever has to know the default. The room's id is 64 hex +characters, so it is a well-formed `ContributorTag` value, and the common case — +"we own what we are making" — comes out as a real entry rather than an absence. + +**`campaign` is out of scope.** There is no `Campaign` or `Pledge` anywhere in this +app — `grep -ril "campaign\|pledge\|satoshi"` over `commonMain` returns the +lightning wallet and `Zap` and nothing else — and nothing in this plan models one, +reads one or leaves a column for one. Funding is a separate feature that needs +projects to exist first. + +**The two join models survive as they are**, minus `creatorId` — see +[Phase 1](#phase-1--three-events-and-their-tags) for why a membership gets no +creator tag of its own — and they are the interesting half. + +## A creator tag, and owners on the `p` tag + +Two fields, two shapes, and the thing that decides which is cardinality. + +**One creator, so a tag of its own.** `CreatorTag`, tag name `creator`, a +single-value tag holding 64 hex characters, in `NameTag`'s shape. A project is +started by one person; there is no list to keep. + +**Many owners, so `ContributorTag` with an owner role.** That is exactly what +`ContributorTag` is for — an open-ended set of people in a named role, which is +also how the translation side uses it for translators, proofreaders and editors. +A project owned jointly is the case the server's `owner` → `ownerEntity` join +exists to express, and a `p` tag per owner expresses it without a second table of +events. + +Keeping them apart is worth a sentence because reusing `ContributorTag` for both +is the obvious move. It is wrong for the creator twice over: it would make a +single-valued field look like a list, and it would put two tags of the same name +on one event for no gain — see [the two traps](#the-two-traps-owners-still-have) +below, both of which the creator now simply avoids. + +### `ROLE_OWNER` has to reach `KNOWN_ROLES`, in the same commit + +```kotlin +private val KNOWN_ROLES = setOf(ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR) +… +val role = tag.getOrNull(2)?.takeIf { it in KNOWN_ROLES } +``` + +The filter is there for a good reason, which the file states: a stale relay hint +copied out of a kind:3 p-tag would otherwise render as `Role: wss://relay…` under +an author chip. The consequence for an unregistered role is specific and silent — +the tag parses, the role comes back `null`, the string stays inside the signed +event id, the event still verifies, and the project lands with no owners. Nothing +is logged, because nothing went wrong as far as the parser is concerned. + +So `ROLE_OWNER = "owner"` goes into the constants **and** into `KNOWN_ROLES`, in +the same commit as the first event that emits it. That is [Phase +1](#phase-1--three-events-and-their-tags), and it is the first line of it. Note +that the app's three existing roles already diverge from NIP-F4, whose +spec-defined set is host/cohost/editor; this continues that divergence rather than +starting it, and the comment above `KNOWN_ROLES` should say so. + +`ROLE_CREATOR` is **not** added. There is no creator role, because the creator is +not a contributor tag. + +### The two traps owners still have + +**`addUnique` keeps one tag per *name*, not per value.** + +```kotlin +fun addUnique(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList[tag[0]] = mutableListOf(tag) // replaces the whole list + return this +} +``` + +Emitting three owners with `addUnique` leaves one. Use `addUniqueValueIfNew`, +which appends and dedupes on `tag[1]` — the pubkey — so a key listed twice by a +form appears once. For a *set* of owners that is the correct semantics; +`addUniqueValueIfNew` was the wrong call only back when creator and owner shared +the tag name and deduping by pubkey would have collapsed a person who was both. + +**Emit them sorted.** The id is a hash over the tag array as emitted, so the order +owners come out in is part of what the group signs. A multi-select hands them over +in selection order, which is nothing in particular. `DkgSession.participantPublicKeys` +already sorts for a version of this reason — "so two devices that assembled the +set in different orders store the same string" — and the same line makes a project +canonical, keeps the owner list on the detail screen stable, and costs a +`.sorted()`. + +**Selecting by role, not position**, is no longer a trap: with the creator gone +from the `p` tag, every `ContributorTag` on a project event is an owner. Filter on +the role anyway — `it.role == ROLE_OWNER` — because that assumption is one future +role away from being false, and the failure would be an owner list quietly +containing somebody else. + +### What the `p` tag costs the owners, and why it is acceptable here + +A `p` tag is not an inert field in nostr. NIP-01 indexes single-letter tag names +and makes them queryable as `#p`, so it means "this pubkey is involved": it is what +relays index, what `Nip17Filters` subscribes on, and what a notification system +reads as a mention. + +Inside a Marmot group none of that applies — these events are never on a relay as +themselves. But `BroadcastGroupSignedEventScreen` takes an event the group signed +and publishes it to public relays, and a broadcast project would put its owners' +keys into those relays' addressed-pubkey index, queryable, with a client free to +notify an owner about a project they were not told about. + +For owners that is tolerable and arguably right: an owner *is* involved, which is +what the tag means, and a project the group chose to publish is one it chose to +publish under their names. It is still worth the broadcast screen saying whose keys +are in what is about to leave, and that belongs on the list of things that screen +should say rather than in this feature. + +The creator escapes this entirely, which is a second small reason the split is the +right one: `creator` is multi-character, so it is not indexed, not queryable and +not an address, and a broadcast project carries the creator as data rather than as +a recipient. + +### One thing not to copy + +Six existing tags carry a stray `Tag` suffix in the *wire value* — `chapterIdTag`, +`chunkIdTag`, `dialectIdTag`, `translationIdTag`, `translationChapterIdTag`, +`translationChunkIdTag` — while `artifactId`, `artifactVersionId` and +`translationArtifactVersionId` do not. It is a copy-paste slip and it is +**unfixable**: those strings are inside event ids the group has already signed, so +renaming them would invalidate every signature over them. The new tag is plain +`creator`, and the slip is worth a comment where somebody would otherwise tidy it. + +## Three kinds, and where they can go + +The nip30303 band is full, and worse than full. + +``` +30300 Artifact 30310 TranslationContributorList / DKG proposal +30301 ArtifactVersion 30311 Translation / DKG host key +30302 Chapter 30312 Submission / DKG round 1 +30303 Chunk 30313 DKG coordinator round 1 +30304 Dialect 30314 DKG round 2 +30305 TranslationAVContributorList 30315 DKG certificate +30306 TranslationArtifactVersion 30316 DKG failure +30307 TranslationChapterContribLst 30320-30325 FROST signing +30308 TranslationChapter 30326 GroupKeyState +30309 TranslationChunk 30327-30328 Chronicle + 30329 SubgroupBirthCertificate +``` + +Three kinds are double-booked. `SubmissionEvent.KIND` is 30312 and +`DkgRitualEvents.ROUND_1` is 30312; 30310 and 30311 collide the same way. It has +been survivable because the two families travelled in different envelopes — a +ceremony's messages were NIP-17 gift wraps between participants and a submission +was a Marmot inner event — and since subgroups moved a child's ChillDKG into the +**parent's Marmot room** that separation is thinner than it was. None of the three +colliding nip30303 kinds has a producer (`TranslationEvent` has an inbound arm and +nothing that builds one; the contributor lists have neither), which is the only +reason this is a latent bug rather than a live one. + +So: do not extend the 303xx nip30303 band, and do not fill the 30317–30319 gap, +which is the ceremony family's growth room. Projects take the three slots after +the highest kind in use: + +``` +30330 ProjectEvent +30331 ProjectArtifactVersionEvent +30332 ProjectTranslationArtifactVersionEvent +``` + +Worth doing in passing, in Phase 0, because it costs a comment and this document +is the only place anyone will look: a note on `DkgRitualEvents` and on +`SubmissionEvent` saying the two bands overlap and which side has no producer. A +collision nobody has written down is one somebody resolves by moving the wrong +family. + +## One event per membership, not one list per project + +Two shapes are available for "the versions in this project". + +**A list event per project**, replaceable, carrying every member as a tag. One +event to sign when membership changes, and removal is free — the new list simply +omits it. + +**An event per membership**, which is what the server's schema describes and what +every other entity in this family is. Append-only; removal needs something extra. + +Take the second, for three reasons. The first is that every nip30303 entity here +is its own event and its own row, and the *only* list-shaped kinds in the family — +the three contributor lists, 30305/30307/30310 — are precisely the ones that were +never made to work. `applyInnerEvent` has no arm that writes a row for any of +them, `MantraTranslationArtifactVersionContributor` has a companion full of +`TODO`s assembling an event out of empty strings, and the chronicle leaves all +three out on the grounds that chronicling them "would cost bytes and restore +nothing". The list shape in this codebase is a shape that has failed twice. + +The second is batch signing. A project with its first contents is a natural batch +— the project as lead, its memberships as dependents, each carrying an id that is +a hash over the group's key at the room's path and so cannot be known by the +screen that proposes it. That is exactly `proposeSigningBatch(lead, dependents)`, +and exactly what an artifact and its initial version already do. A list event +would have to be signed *after* the project, in a second ceremony, because it has +to name the project's id. + +The third is that a membership is a thing a group decided, and one quorum per +decision is the grain this app signs at. A replaceable list makes "we added +Setswana to the project" and "we removed Sesotho from it" the same event, and the +room's record of the first becomes unreadable once the second lands. + +The cost is removal, and it is deferred to Phase 8 rather than solved now — +see [What this does not do](#what-this-does-not-do). + +## The scope fork: one room, or several + +This is the decision that changes the shape of the plan, and it should be made +before Phase 1 rather than discovered in Phase 5. + +Every foreign key on every `Mantra*` entity today points inside one room. A +translation names an artifact version in its room; a chapter names a version in +its room; nothing names anything a member of this room might not hold. A project +is the first entity with an obvious reason to cross that line: a group translating +into Setswana may well want the Sesotho translation that another group produced +counted as part of the same project. + +**Phase 1 through 7 assume one room.** `projectId`, `artifactVersionId` and +`translationArtifactVersionId` are real foreign keys with `onDelete = CASCADE`, +like every sibling. A proposal naming a version this room does not hold is +refused at apply time, which is the same refusal `MantraTranslationArtifactVersion` +already gets for free from its FK. + +**Cross-room is Phase 9, and it is not a small change.** The precedent is +`parentChatRoomId` on `ChatRoom` and `GroupKeyState`, which is deliberately *not* +a foreign key: a subgroup member need never have been in the parent, so a +dangling value is the normal state rather than an error. A cross-room project +membership is in the same position, and so it needs: + +- the FK dropped, and the apply arm's "do we hold it" check replaced with + something that stores the reference unresolved, +- a way to name a version in another room at all — a room id beside the event id, + since an event id alone is unresolvable, +- a UI that can show a member it cannot resolve, honestly, without looking broken, +- and an answer to what the group's signature means over a reference it cannot + check. It means the group asserts the membership; it does not vouch for the + thing named, which no amount of signing can. + +Do not design Phases 1–7 to be cross-room-ready. The FK is load-bearing in Phase +1 (it is what makes a bad proposal fail loudly) and taking it out later is one +migration, which is cheaper than carrying an unverifiable column through seven +phases of a feature that does not use it. + +## Phase 0 — the decisions, written down + +No code. Settle, in this document: + +- the three kind numbers, and the collision note described above; +- one-room or cross-room (recommended: one room, Phase 9 for the rest); +- that the creator is a `creator` tag of its own and the owners are + `ContributorTag`s with an owner role, rather than columns filled from local + knowledge, and that the owner set is never empty and defaults to the room's own + key; +- that `ROLE_OWNER` reaches `KNOWN_ROLES` in the same commit as the first event + that emits it — Phase 1's first line, and the one omission in this plan that + fails without a word anywhere; +- that campaign is out of scope entirely — no model, no column, no reader; +- **the schema version number.** The database is at v20 and the Curated fork + shares this history and allocates version numbers out of the same space — the + record of the last three pulls says v20 on both sides. Whoever lands first + takes 21; check the Curare clone before claiming it, because two trees that + both call their next migration 21 is a merge nobody can fix afterwards. + +## Phase 1 — three events and their tags + +New, under `press.mantra.compose.nostr.nip30303`: + +``` +ProjectEvent.kt 30330 +ProjectArtifactVersionEvent.kt 30331 +ProjectTranslationArtifactVersionEvent.kt 30332 +tags/ProjectIdTag.kt +tags/CreatorTag.kt "creator" +``` + +**First, and in this same commit**, `tags/ContributorTag.kt` learns the owner role: + +```kotlin +const val ROLE_OWNER = "owner" + +private val KNOWN_ROLES = setOf( + ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR, ROLE_OWNER, +) +``` + +Both halves. A role in the constants and not in the set parses back as `null` +while staying inside the signed event id, so the project lands with no owners and +nothing is logged — see [why that is +silent](#role_owner-has-to-reach-known_roles-in-the-same-commit). Extend the +comment above `KNOWN_ROLES` to say that the set has now diverged from NIP-F4's +host/cohost/editor by four rather than three. + +No `ROLE_CREATOR`: the creator is not a contributor tag. + +`CreatorTag` is `NameTag`'s shape with one extra check, since it holds a key +rather than free text: + +```kotlin +class CreatorTag(val pubKey: HexKey) { + companion object { + const val TAG_NAME = "creator" + + fun parse(tag: Array): CreatorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return CreatorTag(tag[1]) + } + + fun assemble(pubKey: HexKey): Array = arrayOf(TAG_NAME, pubKey) + } +} +``` + +The length check is the one guard that matters: without it a malformed value +parses, lands on the row, and names nobody. `ContributorTag` has the same check for +the same reason, which is what makes an owner's key safe to take from a form. + +`ProjectIdTag` is an `EventReference` tag, copied from `ArtifactIdTag` exactly — +the `ensure(tag.has(1))`, the `tag[1].length == 64`, the optional relay hint at +index 2 and pubkey at 3. `ArtifactVersionIdTag` and +`TranslationArtifactVersionIdTag` already exist and are what the two membership +events point with, so no other tag is new. `NameTag` already exists. + +`ProjectEvent`: + +```kotlin +const val KIND = 30330 +const val ALT_DESCRIPTION = "Project" + +fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name + +fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey + +fun owners() = tags.mapNotNull(ContributorTag::parse) + .filter { it.role == ContributorTag.ROLE_OWNER } + .map { it.pubKey } + +fun build( + name: String, + description: String, + creatorPublicKey: HexKey, + ownerPublicKeys: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = eventTemplate(KIND, description, createdAt) { + require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" } + + alt(ALT_DESCRIPTION) + addUnique(NameTag.assemble(name)) + addUnique(CreatorTag.assemble(creatorPublicKey)) + // Sorted, because the id is a hash over the tags as emitted and a form hands + // its selection over in no particular order. addUniqueValueIfNew, not + // addUnique, which would keep one p-tag of the several. + ownerPublicKeys.sorted().forEach { + addUniqueValueIfNew(ContributorTag.assemble(it, ContributorTag.ROLE_OWNER)) + } + initializer() +} +``` + +Content is the description. Name and creator are single-value tags, so `addUnique` +as every sibling does; the owners are a set, so `addUniqueValueIfNew` in sorted +order. `owners()` filters on the role rather than taking every `p` tag, which is +true today and one future role away from not being. + +A `Set` rather than a `List`, and non-empty by contract: the caller always has an +answer, since the room's own key is the default, and an empty set would make "owned +by us" and "owner unstated" the same event. + +Tag order is fixed and starts with `alt`, because the id is a hash over the tags and +anything that rebuilds this event has to emit them in the same order or produce an +event the signature does not cover. For the owners that includes the order among +themselves, which the sort fixes. + +The two membership events carry two id tags and an empty content. `build` takes +both ids: + +```kotlin +fun build( + projectId: HexKey, + artifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + initializer() +} +``` + +Note `TagArrayBuilder` — the real type. Three events +in this package take `TagArrayBuilder` by copy-paste, which is a +phantom generic that happens to compile; do not add a fourth. + +**No creator tag on a membership**, though the server's schema has `creatorId` on +both join models. Two reasons. A membership is an act rather than a work — "this +version is part of this project" is a fact about the project, and who moved it +there is a fact about process, which the `FrostSigningSession` already records. And +a membership proposed in the founding batch has the same creator as the project it +arrives with, so the tag would be redundant in the common case and ambiguous in the +other, where it would be the first place in the app two different people are named +as the creator of one decision. + +If it turns out to be wanted, the cost is a nullable column and one `add` — the +tag parses on an event that lacks it, and a row written without it reads back null. +Nothing in Phases 2 through 7 forecloses it. + +Also in this phase, and the reason it is a phase of its own: a `membershipsOf` +companion on each membership event, the counterpart to +`ArtifactVersionEvent.initialVersionOf`. It takes the signed `project` event and +the list of version ids the form collected, and returns the templates — borrowing +the project's own `createdAt`, so a project and its first contents read as one act +rather than four events that share a session. + +**Tests** (`commonTest`): + +- a `RumorIdAgreementTest` case per kind, in the shape the five existing ones have + — build the template, hash it the way `MantraDao.rumorOf` does, and assert the + row's id agrees; +- a `ContributorTag` case asserting `ROLE_OWNER` survives `parse`. **This is the + test that catches the silent drop**, it is three lines, and without it the only + symptom is projects with no owners; +- a `CreatorTag` case: a well-formed value parses, a value that is not 64 + characters does not; +- a `ProjectEvent` case with **three owners**, asserting all three come back and in + sorted order — the failure mode of `addUnique` is one owner, which looks like a + form bug rather than a builder bug; +- a `ProjectEvent` case where the creator is also an owner, asserting `creator()` + and `owners()` both name them. The two live on different tags now, so this is a + regression test against anyone folding them back together; +- a `ProjectEvent` case asserting `build` rejects an empty owner set. + +## Phase 2 — three rows, and schema 20 → 21 + +New, under `press.mantra.compose.database.model`: + +``` +MantraProject.kt +MantraProjectArtifactVersion.kt +MantraProjectTranslationArtifactVersion.kt +``` + +`MantraProject` is `MantraArtifact`'s shape with fewer fields: + +```kotlin +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class MantraProject( + @PrimaryKey val id: HexKey, + val publicKey: HexKey, // the room's key on a signed row + val name: String, + val description: String, + val creatorPublicKey: HexKey, // off the creator tag, not local knowledge + /** The owner p-tags, comma-joined and sorted. See [ownerPublicKeySet]. */ + val ownerPublicKeys: String, + val chatRoomId: String, + val signature: String, // empty for a rumor + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + val groupSignedEventId: HexKey? = null, + override val nostrEventId: HexKey? = null, + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, +): OptionalNostrEventEntity, TimestampedEntity +``` + +with `fromProjectEvent(event, chatRoomId)` and +`fromProjectEventTemplate(template, chatRoomId, userPublicKey)`, the latter +hashing the id through `EventHasher.hashId` so the row on disk and the payload on +the wire are one event. + +`creatorPublicKey` and `ownerPublicKeys` are non-null and come **only** from +`event.creator()` and `event.owners()`. `fromProjectEvent` returns null when the +creator is missing or the owner list is empty, in the `?.let` chain +`MantraArtifact.fromArtifactEvent` already uses for its four required fields — an +event missing something it was built to carry has either been truncated or is not +what it claims, and a row that fills the gap from whatever this device happens to +know is the failure the tag exists to prevent. + +Never default `creatorPublicKey` to the submitter and never default the owners to +`chatRoomId`; the room's key is the *form's* default, which is a different thing +and belongs in Phase 7. An empty owner list here is the signature of `ROLE_OWNER` +missing from `KNOWN_ROLES`, so the refusal is also the alarm for that bug — which +is the one argument for refusing rather than tolerating it. + +**Owners are one column, not a fourth table.** `DkgSession.participantPublicKeys` +is the precedent: a comma-joined, sorted string with a set accessor beside it, and +the reason it sorts — "so two devices that assembled the set in different orders +store the same string" — holds here too. + +```kotlin +/** The project's owners, or an empty set on a row that somehow has none. */ +fun ownerPublicKeySet(): Set = ownerPublicKeys + .split(",") + .mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } } + .toSet() +``` + +A join table would buy querying by owner, which no screen does — Phase 6 says why +— and cost a table, a DAO, a cascade and three more lines of apply. Revisit it +when something needs "every project this key owns" across rooms, which is Phase 9's +territory anyway. + +The membership rows add the two foreign keys and nothing else. Both get an index +on `projectId`, because every read of them is "this project's contents" — the same +reason v16 added one on `ChatMessage.chatRoomId`. + +**No `toProjectEvent()`.** The eight `toXEvent()` methods exist for one purpose: +rebuilding events for a chronicle in rooms whose work predates `GroupSignedEvent` +(schema v13). They are deprecated, there is a removal checklist in +`docs/member-chronicle.md`, and each one drags a case of +`ChronicleRoundTripTest` behind it. Projects post-date v13 by seven schema +versions, so every project this app will ever hold is on file as a signed event +and `signedEventsOf` finds it. Adding three more rebuilds would be adding to a +mechanism being retired, to serve rows that cannot exist. + +**Schema.** Three tables, nothing existing changes shape, so this is an +`AutoMigration(from = 20, to = 21)` entry with a comment in the style of the other +nineteen, plus three entities in `@Database`, three `abstract fun`s on +`MantraDatabase`, and three DAOs. + +The DAOs are `MantraDialectDao`'s shape — an interface, `@Upsert`, and the queries +each screen needs: + +```kotlin +@Query("SELECT * FROM MantraProject WHERE chatRoomId = :chatRoomId ORDER BY name") +suspend fun getProjectsByChatRoomId(chatRoomId: String): List + +@Query("SELECT * FROM MantraProject WHERE id = :id") +suspend fun getProjectById(id: String): MantraProject? +``` + +and, on the membership DAOs, `getByProjectId` and the inverse +`getProjectsByArtifactVersionId` — the inverse is what lets an artifact's own +screen say which projects it is part of, in Phase 7. + +**Tests** (`jvmTest`): a `MantraProjectDaoJvmTest` in the shape of +`DkgSessionDaoJvmTest` — real in-memory Room over `BundledSQLiteDriver` — covering +upsert, the by-room query, and that a membership naming an absent version is +refused by the FK. That last one is the Phase 1 scope decision made testable. + +## Phase 3 — applying a signed project + +Three arms in `ChatMessage.applyInnerEvent`, which is where every nip30303 kind +lands whether it arrived over the wire or was signed locally — `FrostSigningManager` +reuses this dispatch rather than repeating it. + +`ProjectEvent.KIND` writes the row and a chat line: + +```kotlin +ProjectEvent.KIND -> { + MantraProject.fromProjectEvent( + projectEvent = ProjectEvent(event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig), + chatRoomId = groupId, + )?.let { project -> + database.mantraProjectDao().upsert( + project.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "project", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "Started the ${project.name} project", + ) + + // The line names the project and not its creator. `senderPublicKey` is + // the room's coordinator on a group-signed row, so a line saying "X + // started" would be the transcript's own guess; the creator and the + // owners are on the row, and the detail screen is where they are shown. + } +} +``` + +The two membership arms write their row and **return null** — no line of their +own. This is the convention the whole family follows: a version writes none +beside its artifact, a chunk none beside its chapter, because they arrive as the +later items of a batch whose lead has already said what happened. A membership +added on its own, later, is not silent either: `FrostSigningManager.announce` +already writes "The group signed …" when a session completes, which is the line +that covers it. Three new strings for a transcript the signing session is already +narrating would be three places for the same event to be described differently. + +Both membership arms must tolerate the version not being here. The FK will refuse +the insert; catch it, log it, and return null rather than letting it escape — +`applySignedEvent` swallows throwables for a reason (the signature is valid +whether or not this device could write the row down) but a refusal that reaches it +is reported as a failed apply on a session that succeeded. + +**Tests** (`jvmTest`): a `ProjectApplyJvmTest` in the shape of +`TranslationChunkApplyJvmTest` — apply a project with three owners and assert +`creatorPublicKey` and `ownerPublicKeySet()` are what the event carried; apply a +project event with the `creator` tag stripped and assert **no row is written**; +apply one whose owner tags all carry an unknown role and assert the same, since +that is what a missing `KNOWN_ROLES` entry looks like from here; apply a +membership, assert the row; apply a membership whose version is absent and assert +the project survives. + +## Phase 4 — asking the group + +`ProposedEvent.summarize` gets three branches, or members are asked to sign +"Event of kind 30330". + +```kotlin +ProjectEvent.KIND -> Summary( + label = "New project", + detail = ProjectEvent(/* … */).let { project -> + listOfNotNull( + project.name(), + event.content.takeIf { it.isNotBlank() }, + // Shown because signing this is what makes the group's attestation + // that X started it. A member who cannot see the claim cannot be + // said to have agreed to it -- see the creator note above. + project.creator()?.take(12)?.let { "started by $it" }, + // Only the owners that are not this room. "owned by us" is the + // common case and saying it every time trains members not to read + // the line that matters when somebody else is on it. + project.owners() + .filter { it != localChatRoom.chatRoom.id } + .takeIf { it.isNotEmpty() } + ?.let { others -> + "owned by " + others.joinToString(", ") { it.take(12) } + }, + ).joinToString(" · ") + } +) +``` + +The owner line needs the room's id, which `summarize` does not have: it is a pure +function over one event, by design, so that the signing screen and the proposal +list cannot drift into two names for one thing. Two ways out, and they are not +equally good. Threading the room id through `summarize` is three call sites and no +new concept, but it is the start of passing context into a function whose value is +that it has none. Omitting the comparison and listing every owner is honest and, +with the room always among them, noisy on every single project. + +Take the first, and take it *as* the resolver this phase already needs — the two +problems are one problem. A `summarize(event, resolve)` where `resolve` turns a +pubkey or an event id into a name answers the owner question, the creator question +(`started by a4f1be55d0c3` is barely better than nothing; `started by Kgothatso` +is the point) and the membership question below, all in one change. Both call +sites have a database behind them. Design it once, here, rather than three times. + +The two membership branches are the honest problem in this phase. `summarize` is a +pure function over one event with no database — deliberately, so that the signing +screen and the room's proposal list cannot drift into two names for one thing — +and a membership event carries two 64-character ids and nothing else. The best it +can do is: + +```kotlin +ProjectArtifactVersionEvent.KIND -> Summary( + label = "A source version joins the project", + detail = ProjectArtifactVersionEvent(/* … */) + .artifactVersionId()?.take(12) ?: "Unknown version" +) +``` + +which is a member being asked to sign a truncated hash. It is tolerable **only** +because of the batch: a project and its first contents are proposed together, the +project's own line names the project and says what it is, and the memberships read +as its continuation — the same way "Version of the artifact" reads as the second +line of a batch of two. + +It stops being tolerable in Phase 7's "add an existing version to an existing +project", where the membership is the whole of the proposal — "sign a4f1be55d0c3" +is a request no member can evaluate. The resolver described above is what fixes +this one too, and it is why that design belongs in this phase rather than a later +one: three summaries want the same lookup, and the owner comparison wants it on a +line where getting it wrong is a member agreeing to something they could not read. + +**Tests** (`commonTest`): `ProposedEventTest` cases per branch, plus one asserting +a project whose only owner *is* the room says nothing about owners, and one with +an outside co-owner that names them and not the room. The second is the case the +plural exists for, and the one a member most needs to read before signing. + +## Phase 5 — the chronicle + +Three entries appended to `ChronicleEvent.APPLY_ORDER`: + +```kotlin +private val APPLY_ORDER: List = listOf( + DialectEvent.KIND, + ArtifactEvent.KIND, + ArtifactVersionEvent.KIND, + ChapterEvent.KIND, + ChunkEvent.KIND, + TranslationArtifactVersionEvent.KIND, + TranslationChapterEvent.KIND, + TranslationChunkEvent.KIND, + ProjectEvent.KIND, // new + ProjectArtifactVersionEvent.KIND, // new + ProjectTranslationArtifactVersionEvent.KIND, // new +) +``` + +Order is the whole content of this phase. `APPLY_ORDER` is dependency order and a +page applies front to back, so a membership must sort after *both* the project and +the version it names. Appending gets that right: the memberships are last, and +everything they can name is already in the list. `CHRONICLABLE_KINDS`, +`applyRank`, `inApplyOrder` and `isChroniclable` are all derived from this one +list and need no change. + +Projects are chroniclable where the contributor lists are not, and for the +opposite of their reason: these have an apply arm that writes a row, and they are +signed rather than submitted, so a receiver can check them. A late member who +gets a room's chronicle and not its projects gets the whole library and no account +of what any of it is for. + +`rebuiltEventsOf` — the deprecated walk — is **not** touched, per Phase 2. + +**Tests** (`commonTest` / `jvmTest`): a `ChronicleEventTest` case asserting the +three ranks, specifically that each membership outranks the project and the +version kind it names; a `ChronicleAssemblyJvmTest` case asserting a room with a +project chronicles it. + +## Phase 6 — the repository + +`MantraRepository` gains, with `DatabaseMantraRepository` implementing and `NO_OP` +returning empty: + +```kotlin +suspend fun getProjects(chatRoomId: String): List +suspend fun getProject(id: String): MantraProject? +suspend fun getProjectArtifactVersions(projectId: String): List +suspend fun getProjectTranslationArtifactVersions(projectId: String): List +suspend fun getProjectsForArtifactVersion(artifactVersionId: String): List +``` + +No query by creator or owner. Both are columns on the row and a screen that wants +them has the row; a `getProjectsOwnedBy` would be a query with one caller and an +index to go with it, and the room's project list is short. Add it when a screen +asks, not before. + +The two contents queries return the *versions*, not the join rows: no screen wants +a join row, and resolving it in the repository is one query there instead of an N+1 +in a view model. Room does this with a join; both are indexed by Phase 2. + +**Nothing goes in `MantraDao`.** Its `addDialect` / `addArtifactVersion` / +`rumorOf` / `submitToGroup` path is the old submit flow, reached now only from +`MantraDaoJvmTest`, and every entity a member creates is proposed for signing +instead. An `addProject` there would be a second way to create a project that +bypasses the quorum, which is the thing this feature is for. + +## Phase 7 — the screens + +Five files per screen, in the shape every screen in this app has: a +`@Serializable data class` route, a sealed `UIState` (Loading / Error / Loaded), a +view model with a `factory`, the composable, and a `composable` entry in +`MantraNavHost`. + +**`ChatRoomDetailScreen` gets a projects section**, between the library and the +dialects, copying the artifacts section exactly: a `labelMedium` heading, an empty +line when there are none, a `Card` + `ListItem` per project navigating to +`ProjectDetailRoute`, and a `TextButton` to `AddProjectRoute`. +`ChatRoomDetailUIState.Loaded` gains `projects: List = emptyList()` +and the view model's `initiate` loads them. + +**`AddProjectScreen` / `AddProjectViewModel` / `AddProjectRoute`.** Name and +description fields, a multi-select of the room's artifact versions and a +multi-select of its translations, an owner, and one primary action. The view model +is `AddArtifactViewModel`'s shape, including the parts that are not obvious: + +- the **creator is `activeUserPublicKey`**, not a field. It is who is filling the + form in, the view model already has it, and a picker would only let somebody + credit the wrong person; +- the **owners are a list, pre-filled with the room's own id** and labelled as this + group, with an add control and a remove on each. Most projects never touch it, + and that is the point of pre-filling rather than hiding: a group that *is* making + something for somebody else needs the question asked, and a group that is not + needs to see the answer it is giving. Each entry takes a pubkey or an npub; + validate to 64 hex on entry, since `ContributorTag.parse` drops a tag whose value + is not that length and the owner would vanish between the form and the signature. + The list cannot be emptied — `build` requires one and the last remove is + disabled rather than allowed and then refused; +- `isActionPending`, guarding the double tap; +- `canSign` on `UIState.Loaded` from `frostSigningRepository.canSign`, with the + FAB borrowing `ButtonDefaults.buttonColors().disabled*` and + `Modifier.semantics { disabled() }` when it cannot; +- `proposeSigningBatch(lead = projectTemplate, dependents = …)`, where + `dependents` reads the signed project's id and returns both membership lists. + The project's id is a hash over the group's key at the room's path, which this + screen does not know and should not; +- on success, navigate to `FrostSigningRoute(session.id)` with + `popUpTo { inclusive = true }`. + +A project with no contents is allowed — unlike an artifact with no version, which +is inert because nothing can attach to it, an empty project is a name waiting for +work and Phase 7's second flow is how work reaches it. + +**`ProjectDetailScreen` / `ProjectDetailViewModel` / `ProjectDetailRoute`.** Name, +description, creator, owner, the source versions, the translations, and two add +buttons. Lists load once in `initiate()`; to reflect a just-signed change, navigate +to a fresh `ProjectDetailRoute` with `popUpTo { inclusive = true }`. +`DetailRow` is the shared label/value widget. + +Creator is a `DetailRow` and the owners are a short list, both showing the +**profile** rather than the hex — resolve through `ProfileDao` and fall back to a +truncated npub, which is what the rest of the app does with a key it has no profile +for. An owner entry that is the room's own key reads "this group" rather than +printing the room id back at a member standing inside it, and sorts first, so the +common single-owner case is one unremarkable line. + +**`AddToProjectScreen`** — pick a version, propose one membership. **This is the +flow that needs Phase 4's resolver**, because here the membership is the entire +proposal and a truncated hash is not a thing to sign. + +Each screen obeys `CLAUDE.md`, and the audit is not optional — +`docs/scripts/m3-audit.sh --check` runs inside `./gradlew check` and fails on a +budget exceeded: + +- spacing from `MaterialTheme.spacing`, never a `.dp` literal in a spacing + position (budget 0); +- colour from a role, never a `Color(0x…)` and effectively never `.copy(alpha=)` + (budget 0 outside `ui/theme/`); +- every string in `composeResources/values/strings.xml`, **sentence case** — + "Add project", "No projects in this group", not "Add Project" (budget 0 + title-case strings); +- `NavigateBackButton(onNavigateBack)` in the app bar of all four, since all four + are pushed and the desktop target has neither a system back nor an edge swipe; +- `Modifier.padding(innerPadding).readableContent()` on each content root; +- all four states said, with `ErrorState(onRetry = …)` and `EmptyState`'s message + given explicitly; +- `ScreenStateTransition(viewModel.uiState) { … }` wrapping the state `when`, and + only where the `when` is the composable's whole body; +- icons with a decided `contentDescription` — a real one, or `Decorative` where + the label is beside it, never a bare `null`. + +Around twenty new strings — the six beyond the screens themselves are the owners +field's label, its "this group" value, its add and remove actions, its validation +error, and the creator row's label. Check `grep -c " Date: Fri, 2 Oct 2026 19:00:51 +0200 Subject: [PATCH 06/32] feat(projects): three kinds, a creator tag, and the owner role the parser needs Phase 1 of docs/project-feature.md. Nothing emits these yet -- no screen, no repository, no apply arm -- so this is three kinds the app can build and parse and nothing else. The kinds are 30330-30332, not the next free number in the nip30303 band. That band is not merely full: 30310, 30311 and 30312 are double-booked with the ceremony family, survivable only because none of the three nip30303 kinds has a producer. 30317-30319 is the ceremony family's growth room. So projects start after the highest kind in use. A project names people two ways because they are two shapes. One creator, so `CreatorTag` -- a single-value tag like every other field here, and multi-character on purpose: NIP-01 indexes single-letter names, so a `p` tag is an address, and a broadcast project should carry its creator as data rather than as a recipient. Several owners, so `ContributorTag` with `ROLE_OWNER`, which is what that tag is for. `ROLE_OWNER` goes into `KNOWN_ROLES` in this same commit, which is the whole reason that line is first. `ContributorTag.parse` drops a role it does not know -- rightly, since a stale relay hint would otherwise render as a role -- and the consequence for an unregistered one is that the tag parses, the role comes back null, the string stays inside the signed event id, the event still verifies, and the project lands owned by nobody. Nothing is logged. `ProjectEventTest` has the three-line test that would say so. Two other things that fail quietly, both in `ProjectEvent.build`. `addUniqueValueIfNew` rather than `addUnique`, because `addUnique` keeps one tag per *name* and three owners would come out as one. And sorted, because the id is a hash over the tags as emitted and a multi-select hands its selection over in no particular order -- the same reason `DkgSession.participantPublicKeys` sorts. A membership is its own event rather than a line in a list the project carries. The only list-shaped kinds in this family are the three contributor lists, which are precisely the ones nothing applies; and one quorum per decision is the grain this app signs at, which a replaceable list would lose. `membershipsOf` mirrors `ArtifactVersionEvent.initialVersionOf`: built from the project *after* the session has authored it, since the id it carries is a hash over the group's key at the room's path and no screen can know it beforehand. Co-Authored-By: Claude Opus 5 --- .../nip30303/ProjectArtifactVersionEvent.kt | 92 ++++++++ .../compose/nostr/nip30303/ProjectEvent.kt | 107 +++++++++ .../ProjectTranslationArtifactVersionEvent.kt | 67 ++++++ .../nostr/nip30303/tags/ContributorTag.kt | 22 +- .../compose/nostr/nip30303/tags/CreatorTag.kt | 44 ++++ .../nostr/nip30303/tags/ProjectIdTag.kt | 44 ++++ .../nostr/nip30303/ProjectEventTest.kt | 207 ++++++++++++++++++ 7 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt new file mode 100644 index 00000000..071399fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt @@ -0,0 +1,92 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag + + +/** + * One source version is part of one project. + * + * Its own event rather than a line in a list the project carries. Every entity + * in this family is its own event and its own row, and the only list-shaped kinds + * here -- the three contributor lists -- are precisely the ones nothing applies. + * A membership is also something a group decided, and one quorum per decision is + * the grain this app signs at: a replaceable list would make "we added this" and + * "we removed that" the same event. + * + * It carries no creator. Who moved a version into a project is a fact about + * process, which the `FrostSigningSession` records; a membership signed in the + * batch that founds a project has the project's creator anyway. + */ +@Immutable +class ProjectArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun artifactVersionIdReference() = tags.firstNotNullOfOrNull(ArtifactVersionIdTag::parse)?.ref + fun artifactVersionId() = artifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30331 + const val ALT_DESCRIPTION = "ProjectArtifactVersion" + + /** + * The source versions [project] starts life with, ready to be signed + * with it. + * + * A project and its first contents go to the group as one batch, so this + * is built from the project *after* it has been authored under the + * group's key -- [project] is the unsigned event the session will sign, + * which is where the id these carry comes from. Working it out anywhere + * else would mean naming a project id before one exists: it is a hash + * over the group's key at the room's path, and neither is resolved until + * the proposal is made. + * + * They take the project's own timestamp, so the batch reads as one act + * rather than several events that happen to share a session. + * + * The same shape as `ArtifactVersionEvent.initialVersionOf`, and empty + * for a project founded with no contents -- which is allowed, unlike an + * artifact with no version: an empty project is a name waiting for work. + */ + fun membershipsOf( + project: Event, + artifactVersionIds: List, + ): List> = + artifactVersionIds.map { artifactVersionId -> + build( + projectId = project.id, + artifactVersionId = artifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + artifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt new file mode 100644 index 00000000..3fdc8d48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt @@ -0,0 +1,107 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import press.mantra.compose.nostr.nip30303.tags.NameTag + + +/** + * A body of work: the source versions to translate from and the translations + * being produced against them, under one name. + * + * A project names two kinds of person and they are not the same shape. There is + * one [creator], so it is a [CreatorTag] -- a single-value tag, like every other + * field here. There may be several [owners], so each is a [ContributorTag] with + * [ContributorTag.ROLE_OWNER], which is what that tag is for. + * + * Both are written before the event is proposed, so they are inside the id the + * quorum signs. That makes them the group's statement about whose work this is + * rather than one device's note about who inserted a row -- and it is a group + * attestation, not proof of authorship: the creator does not sign this, the room + * does, so a quorum that signs without reading has credited whoever the proposer + * typed. `ProposedEvent.summarize` shows both for that reason. + * + * Which group a project belongs to is not a tag at all. It is signed by the + * group's threshold key at the room's derivation path, so it says so by existing. + */ +@Immutable +class ProjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name + + fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey + + /** + * Everyone this project belongs to, in the order the event carries them. + * + * Filtered on the role rather than taking every `p` tag. Every contributor + * tag on a project is an owner today, and that is one future role away from + * being false -- at which point an unfiltered read would quietly hand back an + * owner list with somebody else in it. + * + * Empty is not a project this app wrote: [build] refuses it. It is what a + * reader sees when [ContributorTag.ROLE_OWNER] is missing from that tag's + * known roles, since the role then parses as null -- so treat empty as that + * bug rather than as a project nobody owns. + */ + fun owners() = tags.mapNotNull(ContributorTag::parse) + .filter { it.role == ContributorTag.ROLE_OWNER } + .map { it.pubKey } + + companion object { + const val KIND = 30330 + const val ALT_DESCRIPTION = "Project" + + /** + * [ownerPublicKeys] is a set and must not be empty. + * + * The caller always has an answer -- the room's own key, when the group + * owns what it makes -- and an empty set would make "owned by us" and + * "owner unstated" the same event, which leaves every reader needing to + * know the default. + */ + fun build( + name: String, + description: String, + creatorPublicKey: HexKey, + ownerPublicKeys: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" } + + alt(ALT_DESCRIPTION) + addUnique(NameTag.assemble(name)) + addUnique(CreatorTag.assemble(creatorPublicKey)) + + // Sorted, because the id is a hash over the tags as emitted and a + // multi-select hands its selection over in no particular order -- + // the same reason DkgSession.participantPublicKeys sorts. + // + // addUniqueValueIfNew, never addUnique: addUnique keeps one tag per + // *name*, so three owners would come out as one and the loss is + // silent. Deduping on the pubkey is the right thing for a set. + ownerPublicKeys.sorted().forEach { ownerPublicKey -> + addUniqueValueIfNew( + ContributorTag.assemble(ownerPublicKey, ContributorTag.ROLE_OWNER) + ) + } + + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt new file mode 100644 index 00000000..d2119485 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt @@ -0,0 +1,67 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag +import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag + + +/** + * One translation is part of one project. + * + * The other half of [ProjectArtifactVersionEvent], and the same shape for the + * same reasons -- see there. + */ +@Immutable +class ProjectTranslationArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun translationArtifactVersionIdReference() = + tags.firstNotNullOfOrNull(TranslationArtifactVersionIdTag::parse)?.ref + fun translationArtifactVersionId() = translationArtifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30332 + const val ALT_DESCRIPTION = "ProjectTranslationArtifactVersion" + + /** The translations [project] starts life with. See the sibling's note. */ + fun membershipsOf( + project: Event, + translationArtifactVersionIds: List, + ): List> = + translationArtifactVersionIds.map { translationArtifactVersionId -> + build( + projectId = project.id, + translationArtifactVersionId = translationArtifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + translationArtifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt index d73e9e3e..f01ffdbf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt @@ -20,7 +20,27 @@ class ContributorTag( const val ROLE_PROOF_READER = "proofreader" const val ROLE_EDITOR = "editor" - private val KNOWN_ROLES = setOf(ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR) + /** + * Whoever the work belongs to, which need not be whoever is doing it. + * + * A project carries one of these per owner -- the room's own key when the + * group owns what it makes, and somebody else's when it does not. There + * is no matching creator role: a project has exactly one creator and a + * single-valued field gets a tag of its own, which is `CreatorTag`. + */ + const val ROLE_OWNER = "owner" + + // Four, where NIP-F4 defines host/cohost/editor. A role missing from this + // set is the quietest bug in the family: `parse` returns the tag with a + // null role, the string stays inside the signed event id, the event still + // verifies, and whatever was reading for that role finds nothing. Add to + // both halves or to neither. + private val KNOWN_ROLES = setOf( + ROLE_TRANSLATOR, + ROLE_PROOF_READER, + ROLE_EDITOR, + ROLE_OWNER, + ) fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt new file mode 100644 index 00000000..a3868e74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt @@ -0,0 +1,44 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Whoever started the thing this tag is on. + * + * A tag of its own rather than a [ContributorTag] with a creator role, because + * there is exactly one creator and a single-valued field is what every other tag + * in this package is. The owners are the other shape -- a set, open-ended, and so + * [ContributorTag] with [ContributorTag.ROLE_OWNER]. + * + * The name is multi-character on purpose. NIP-01 indexes single-letter tag names + * and makes them queryable, so a `p` tag is an address: relays index it and a + * client may read it as a mention. These events live inside a Marmot group and + * are never on a relay as themselves, but `BroadcastGroupSignedEventScreen` can + * put one there, and a creator should travel in that as data rather than as a + * recipient. + */ +class CreatorTag( + val pubKey: HexKey, +) { + fun toTagArray() = assemble(pubKey = pubKey) + + companion object { + const val TAG_NAME = "creator" + + fun parse(tag: Array): CreatorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + // Without this a malformed value parses, lands on the row, and names + // nobody. ContributorTag checks the same thing for the same reason. + ensure(tag[1].length == 64) { return null } + + return CreatorTag(pubKey = tag[1]) + } + + fun assemble(pubKey: HexKey): Array = arrayOf(TAG_NAME, pubKey) + + fun assemble(creatorTag: CreatorTag) = assemble(pubKey = creatorTag.pubKey) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt new file mode 100644 index 00000000..a5817693 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt @@ -0,0 +1,44 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +data class ProjectIdTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this( + EventReference(eventId, pubkey, relayHint) + ) + + companion object { + // Plain, like `artifactId`. Six tags in this package carry a stray `Tag` + // suffix in the wire value -- `chapterIdTag` and friends -- which is a + // copy-paste slip and is unfixable: those strings are inside event ids + // the group has signed, so renaming one invalidates every signature over + // it. Do not copy it and do not tidy it. + const val TAG_NAME = "projectId" + + fun parse(tag: Array): ProjectIdTag? { + 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 ProjectIdTag(tag[1], relayHint, tag.getOrNull(3)) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl? = null, + pubkey: String? = null, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) + + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} 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 new file mode 100644 index 00000000..e0c2fea9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt @@ -0,0 +1,207 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A project names people two different ways, and both ways fail quietly. + * + * The creator is a [CreatorTag] and the owners are [ContributorTag]s with + * [ContributorTag.ROLE_OWNER]. Each has a failure mode that produces a valid, + * verifiable event carrying nobody: + * + * - a role missing from `ContributorTag`'s known set parses as null, so every + * owner vanishes while the role string stays inside the signed id; + * - `addUnique` keeps one tag per *name*, so three owners emitted with it + * become one, which reads like a bug in whatever collected them. + * + * Neither logs anything. These tests are the only thing that would say. + */ +class ProjectEventTest { + private val creator = "a".repeat(64) + private val owner1 = "1".repeat(64) + private val owner2 = "2".repeat(64) + private val owner3 = "3".repeat(64) + + private fun EventTemplate.asEvent() = ProjectEvent( + id = "e".repeat(64), + pubKey = creator, + createdAt = createdAt, + tags = tags, + content = content, + sig = "" + ) + + private fun project(owners: Set) = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters, with the Setswana back-translation.", + creatorPublicKey = creator, + ownerPublicKeys = owners + ).asEvent() + + // --- the role has to be known, or there are no owners at all --- + + @Test + fun `the owner role survives a round trip through ContributorTag`() { + val parsed = ContributorTag.parse( + ContributorTag.assemble(owner1, ContributorTag.ROLE_OWNER) + ) + + assertEquals(owner1, parsed?.pubKey) + // If ROLE_OWNER is ever dropped from KNOWN_ROLES this is the only place + // that says so. Everything downstream just sees a project with no owners. + assertEquals(ContributorTag.ROLE_OWNER, parsed?.role) + } + + // --- the creator tag --- + + @Test + fun `a creator tag round trips`() { + assertEquals(creator, CreatorTag.parse(CreatorTag.assemble(creator))?.pubKey) + } + + @Test + fun `a creator tag whose value is not a key is refused`() { + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME, "not-a-key"))) + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME))) + assertNull(CreatorTag.parse(arrayOf("p", creator))) + } + + // --- owners are a set, and all of them survive --- + + @Test + 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 + // the tags as emitted and a form hands its selection over in no + // particular order. + assertEquals(listOf(owner1, owner2, owner3), event.owners()) + } + + @Test + fun `an owner listed twice appears once`() { + assertEquals(listOf(owner1), project(setOf(owner1)).owners()) + } + + @Test + fun `a project with no owners cannot be built`() { + assertFailsWith { + ProjectEvent.build( + name = "Nobody's", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = emptySet() + ) + } + } + + @Test + fun `owners ignores a contributor tag that is not an owner`() { + val event = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = setOf(owner1) + ) { + addUniqueValueIfNew( + ContributorTag.assemble(owner2, ContributorTag.ROLE_EDITOR) + ) + }.asEvent() + + assertEquals(listOf(owner1), event.owners()) + } + + // --- the creator and the owners are different tags, so one can be both --- + + @Test + fun `the creator may also be an owner`() { + val event = project(setOf(creator, owner1)) + + assertEquals(creator, event.creator()) + assertTrue(creator in event.owners()) + assertTrue(owner1 in event.owners()) + } + + @Test + fun `the creator is not read off the owner tags`() { + val event = project(setOf(owner1)) + + // Selecting the creator by position among p-tags would make these agree, + // which reads as working. + assertEquals(creator, event.creator()) + assertEquals(listOf(owner1), event.owners()) + } + + @Test + fun `name and description land where they are read from`() { + val event = project(setOf(owner1)) + + assertEquals("Gospel of John into Sesotho", event.name()) + assertTrue(event.content.startsWith("The four chapters")) + } + + // --- memberships borrow the project's id and timestamp --- + + @Test + fun `memberships name the project and share its timestamp`() { + val signedProject: Event = project(setOf(owner1)) + val versionId = "f".repeat(64) + + val sources = ProjectArtifactVersionEvent.membershipsOf( + project = signedProject, + artifactVersionIds = listOf(versionId) + ) + val translations = ProjectTranslationArtifactVersionEvent.membershipsOf( + project = signedProject, + translationArtifactVersionIds = listOf(versionId) + ) + + val source = ProjectArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = sources.single().createdAt, + tags = sources.single().tags, + content = sources.single().content, + sig = "" + ) + + assertEquals(signedProject.id, source.projectId()) + assertEquals(versionId, source.artifactVersionId()) + assertEquals(signedProject.createdAt, source.createdAt) + + val translation = ProjectTranslationArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = translations.single().createdAt, + tags = translations.single().tags, + content = translations.single().content, + sig = "" + ) + + assertEquals(signedProject.id, translation.projectId()) + assertEquals(versionId, translation.translationArtifactVersionId()) + assertEquals(signedProject.createdAt, translation.createdAt) + } + + @Test + fun `a project founded with no contents has no memberships`() { + val signedProject: Event = project(setOf(owner1)) + + assertTrue( + ProjectArtifactVersionEvent.membershipsOf(signedProject, emptyList()).isEmpty() + ) + assertTrue( + ProjectTranslationArtifactVersionEvent + .membershipsOf(signedProject, emptyList()) + .isEmpty() + ) + } +} From 7afda9cf80e0fad0ecf20b8705feafdd82d88574 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:07:01 +0200 Subject: [PATCH 07/32] feat(projects): three tables, schema 20 -> 21 Phase 2 of docs/project-feature.md. Still nothing that writes a row: this is the shape, the keys and the queries each screen will want. A project's contents are real foreign keys into this room's own rows, with CASCADE, like every reference in this family. That is the one-room scope decision made load-bearing rather than documented: a membership naming a version this device does not hold fails at insert, loudly, instead of landing as a row pointing at nothing that every later read has to be defensive about. Projects that span rooms take those keys away again -- Phase 9, following `parentChatRoomId`, which is deliberately not a foreign key because a dangling value is the normal state there -- and that is a migration rather than something to carry unused until then. `MantraProjectDaoJvmTest` asserts the refusal, which is also what says Room has foreign keys switched on at all. The owners are a comma-joined sorted string with an `ownerPublicKeySet()` accessor beside it, which is what `DkgSession.participantPublicKeys` already does with a set of keys, sort included and for the same reason. A fourth table would buy querying by owner, which no screen does. A set stored as text is a shape that rots, so the accessor is tested against spacing, empty entries and the single-entry case rather than the happy path. `fromProjectEvent` refuses an event with no name, no creator or no owners rather than filling the gap from local knowledge -- which is exactly the failure the tags exist to prevent. The empty-owner refusal does double duty: an owner list that comes back empty is what `ROLE_OWNER` missing from `KNOWN_ROLES` looks like from here, so the guard against a malformed event is also the alarm for that bug. No `toProjectEvent()`. Those eight rebuilds serve rooms whose work predates `GroupSignedEvent` at v13; projects arrive at v21, so every project this app will ever hold is on file as a signed event. Adding three more would be adding to a mechanism with a removal checklist already written for it. Co-Authored-By: Claude Opus 5 --- .../21.json | 6102 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 33 +- .../dao/MantraProjectArtifactVersionDao.kt | 47 + .../compose/database/dao/MantraProjectDao.kt | 18 + ...traProjectTranslationArtifactVersionDao.kt | 49 + .../compose/database/model/MantraProject.kt | 165 + .../model/MantraProjectArtifactVersion.kt | 125 + ...MantraProjectTranslationArtifactVersion.kt | 123 + .../database/model/RumorIdAgreementTest.kt | 46 + .../database/dao/MantraProjectDaoJvmTest.kt | 355 + 10 files changed, 7062 insertions(+), 1 deletion(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectArtifactVersionDao.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectDao.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectTranslationArtifactVersionDao.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectArtifactVersion.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectTranslationArtifactVersion.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json new file mode 100644 index 00000000..a21a6651 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json @@ -0,0 +1,6102 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "ccbc98e82860c74663f3faed72cd1395", + "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, `parentChatRoomId` TEXT, `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": "parentChatRoomId", + "columnName": "parentChatRoomId", + "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": "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, `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": "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, `parentChatRoomId` TEXT, `birthCertificateJson` TEXT, `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": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "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": [ + "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": "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, 'ccbc98e82860c74663f3faed72cd1395')" + ] + } +} \ 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 dcd795cd..66a89663 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -29,6 +29,9 @@ import press.mantra.compose.database.dao.MantraChapterDao import press.mantra.compose.database.dao.MantraChunkDao import press.mantra.compose.database.dao.MantraDao import press.mantra.compose.database.dao.MantraDialectDao +import press.mantra.compose.database.dao.MantraProjectArtifactVersionDao +import press.mantra.compose.database.dao.MantraProjectDao +import press.mantra.compose.database.dao.MantraProjectTranslationArtifactVersionDao import press.mantra.compose.database.dao.MantraTranslationArtifactVersionDao import press.mantra.compose.database.dao.MantraTranslationArtifactVersionContributorDao import press.mantra.compose.database.dao.MantraTranslationChapterDao @@ -81,6 +84,9 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectArtifactVersion +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslation import press.mantra.compose.database.model.MantraTranslationArtifactVersion import press.mantra.compose.database.model.MantraTranslationArtifactVersionContributor @@ -146,6 +152,9 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) MantraChapter::class, MantraChunk::class, MantraDialect::class, + MantraProject::class, + MantraProjectArtifactVersion::class, + MantraProjectTranslationArtifactVersion::class, MantraTranslation::class, MantraTranslationArtifactVersion::class, MantraTranslationArtifactVersionContributor::class, @@ -177,7 +186,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 20, + version = 21, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -321,6 +330,22 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // which is what they were, and any identity may drain them. The broadcast // queue deliberately gets no owner: a signed event is anyone's to carry. AutoMigration(from = 19, to = 20), + // v21 adds the three project tables -- a project, and one row per source + // version or translation that is part of it. Three new tables and + // nothing existing changes shape, so Room generates this itself. + // + // A project's contents are foreign keys into this room's own rows, with + // CASCADE, like every other reference in this family. That is the + // one-room scope decision made load-bearing: a membership naming a + // version this device does not hold fails at insert rather than landing + // as a row pointing at nothing. Projects that span rooms take those keys + // away again -- see Phase 9 of docs/project-feature.md -- and that is a + // migration rather than something to carry unused until then. + // + // The owners are a comma-joined sorted string on the project row rather + // than a fourth table, which is what DkgSession.participantPublicKeys + // already does with a set of keys and for the same reason. + AutoMigration(from = 20, to = 21), ] ) @ColumnTypeConverters(MantraConverters::class) @@ -368,6 +393,12 @@ abstract class MantraDatabase: RoomDatabase() { abstract fun mantraDialectDao(): MantraDialectDao + abstract fun mantraProjectDao(): MantraProjectDao + + abstract fun mantraProjectArtifactVersionDao(): MantraProjectArtifactVersionDao + + abstract fun mantraProjectTranslationArtifactVersionDao(): MantraProjectTranslationArtifactVersionDao + abstract fun mantraTranslationArtifactVersionDao(): MantraTranslationArtifactVersionDao abstract fun mantraTranslationArtifactVersionContributorDao(): MantraTranslationArtifactVersionContributorDao diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectArtifactVersionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectArtifactVersionDao.kt new file mode 100644 index 00000000..1d0a44a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectArtifactVersionDao.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectArtifactVersion + +@Dao +interface MantraProjectArtifactVersionDao { + @Upsert + suspend fun upsert(mantraProjectArtifactVersion: MantraProjectArtifactVersion) + + @Query("SELECT * FROM MantraProjectArtifactVersion WHERE projectId = :projectId") + suspend fun getByProjectId(projectId: String): List + + @Query("SELECT * FROM MantraProjectArtifactVersion WHERE id = :id") + suspend fun getById(id: String): MantraProjectArtifactVersion? + + /** + * The versions themselves, resolved here rather than by the caller. + * + * No screen wants a join row, and resolving it in one indexed query beats an + * N+1 in a view model. + */ + @Query( + """ + SELECT v.* FROM MantraArtifactVersion v + INNER JOIN MantraProjectArtifactVersion m ON m.artifactVersionId = v.id + WHERE m.projectId = :projectId + ORDER BY v.versionLabel + """ + ) + suspend fun getArtifactVersionsByProjectId(projectId: String): List + + /** The inverse: which projects a version is part of. */ + @Query( + """ + SELECT p.* FROM MantraProject p + INNER JOIN MantraProjectArtifactVersion m ON m.projectId = p.id + WHERE m.artifactVersionId = :artifactVersionId + ORDER BY p.name + """ + ) + suspend fun getProjectsByArtifactVersionId(artifactVersionId: String): List +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectDao.kt new file mode 100644 index 00000000..d6954b5f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectDao.kt @@ -0,0 +1,18 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import press.mantra.compose.database.model.MantraProject + +@Dao +interface MantraProjectDao { + @Upsert + suspend fun upsert(mantraProject: MantraProject) + + @Query("SELECT * FROM MantraProject WHERE chatRoomId = :chatRoomId ORDER BY name") + suspend fun getProjectsByChatRoomId(chatRoomId: String): List + + @Query("SELECT * FROM MantraProject WHERE id = :id") + suspend fun getProjectById(id: String): MantraProject? +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectTranslationArtifactVersionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectTranslationArtifactVersionDao.kt new file mode 100644 index 00000000..49f3d3e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/MantraProjectTranslationArtifactVersionDao.kt @@ -0,0 +1,49 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion +import press.mantra.compose.database.model.MantraTranslationArtifactVersion + +@Dao +interface MantraProjectTranslationArtifactVersionDao { + @Upsert + suspend fun upsert( + mantraProjectTranslationArtifactVersion: MantraProjectTranslationArtifactVersion + ) + + @Query("SELECT * FROM MantraProjectTranslationArtifactVersion WHERE projectId = :projectId") + suspend fun getByProjectId(projectId: String): List + + @Query("SELECT * FROM MantraProjectTranslationArtifactVersion WHERE id = :id") + suspend fun getById(id: String): MantraProjectTranslationArtifactVersion? + + /** The translations themselves. See the sibling DAO's note. */ + @Query( + """ + SELECT t.* FROM MantraTranslationArtifactVersion t + INNER JOIN MantraProjectTranslationArtifactVersion m + ON m.translationArtifactVersionId = t.id + WHERE m.projectId = :projectId + ORDER BY t.name + """ + ) + suspend fun getTranslationArtifactVersionsByProjectId( + projectId: String + ): List + + /** The inverse: which projects a translation is part of. */ + @Query( + """ + SELECT p.* FROM MantraProject p + INNER JOIN MantraProjectTranslationArtifactVersion m ON m.projectId = p.id + WHERE m.translationArtifactVersionId = :translationArtifactVersionId + ORDER BY p.name + """ + ) + suspend fun getProjectsByTranslationArtifactVersionId( + translationArtifactVersionId: String + ): List +} 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 new file mode 100644 index 00000000..abbf505f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProject.kt @@ -0,0 +1,165 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ProjectEvent +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * 30330 + * + * No `toProjectEvent()`, deliberately. The eight `toXEvent()` methods exist to + * rebuild events for a chronicle in rooms whose work predates `GroupSignedEvent` + * (schema v13); they are deprecated and there is a removal checklist in + * docs/member-chronicle.md. Projects arrive at v21, so every project this app + * will ever hold is on file as a signed event and `signedEventsOf` finds it. A + * rebuild here would be adding to a mechanism being retired, to serve rows that + * cannot exist. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("chatRoomId"), + ], +) +data class MantraProject( + @PrimaryKey + val id: HexKey, + + /** The room's own key on a group-signed row, which is every project. */ + val publicKey: HexKey, + + val name: String, + val description: String, + + /** + * Who started it, off the event's `creator` tag. + * + * Never the submitter, and never filled in from what this device happens to + * know: the point of the tag is that it is inside the id the quorum signed, + * and a column that could disagree with it would be worth less than no + * column. It is the group's claim about whose work this is rather than proof + * -- the creator signs nothing, the room does -- and the fact about who + * actually acted is on the `FrostSigningSession`. + */ + val creatorPublicKey: HexKey, + + /** + * Who it belongs to: the owner contributor tags, comma-joined and sorted. + * Read it through [ownerPublicKeySet]. + * + * One column rather than a join table, following + * `DkgSession.participantPublicKeys` -- including the sort, which is there so + * two devices that assembled the set in different orders store the same + * string. A table would buy querying by owner, which no screen does. + * + * Never empty: `ProjectEvent.build` refuses an empty set and + * [fromProjectEvent] refuses an event with no owners. + */ + val ownerPublicKeys: String, + + val chatRoomId: String, + + /** Signature will be an empty string if this is a rumor */ + val signature: String, + + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + + /** The [GroupSignedEvent] this row was made from, when the group signed it. */ + val groupSignedEventId: HexKey? = null, + + /** Related nostrEventId if this is not a rumor... */ + override val nostrEventId: HexKey? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt +): OptionalNostrEventEntity, TimestampedEntity { + + /** The owners as the set they are. Empty only on a row written around this. */ + fun ownerPublicKeySet(): Set = ownerPublicKeys + .split(",") + .mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } } + .toSet() + + /** Whether [publicKey] owns this, for a screen deciding what to say. */ + fun isOwnedBy(publicKey: HexKey) = publicKey in ownerPublicKeySet() + + companion object { + fun fromProjectEventTemplate( + projectEventTemplate: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraProject? { + return fromProjectEvent( + ProjectEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = projectEventTemplate.tags, + content = projectEventTemplate.content, + createdAt = projectEventTemplate.createdAt, + kind = projectEventTemplate.kind, + ), + content = projectEventTemplate.content, + tags = projectEventTemplate.tags, + createdAt = projectEventTemplate.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + + /** + * Null for an event missing anything it was built to carry. + * + * The name, the creator and at least one owner are all required, and an + * event without one of them has either been truncated or is not what it + * claims. Filling the gap from local knowledge is exactly the failure the + * tags exist to prevent, so this refuses instead. + * + * An empty owner list is also what `ContributorTag.ROLE_OWNER` missing + * from that tag's known roles looks like from here -- the role parses as + * null and every owner disappears -- so refusing is the alarm for that + * bug as well as the guard against a malformed event. + */ + fun fromProjectEvent( + projectEvent: ProjectEvent, + chatRoomId: HexKey, + ): MantraProject? { + val name = projectEvent.name() ?: return null + val creatorPublicKey = projectEvent.creator() ?: return null + val owners = projectEvent.owners().takeIf { it.isNotEmpty() } ?: return null + + return MantraProject( + id = projectEvent.id, + publicKey = projectEvent.pubKey, + name = name, + description = projectEvent.content, + creatorPublicKey = creatorPublicKey, + // Sorted here as well as in the event. The event's order is what + // the signature covers; this is so two rows built from the same + // owners compare equal whatever order they arrived in. + ownerPublicKeys = owners.toSortedSet().joinToString(","), + chatRoomId = chatRoomId, + signature = projectEvent.sig, + createdAt = Instant.fromEpochSeconds(projectEvent.createdAt), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectArtifactVersion.kt new file mode 100644 index 00000000..5353c9ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectArtifactVersion.kt @@ -0,0 +1,125 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * 30331 -- one source version is part of one project. + * + * Both references are real foreign keys, which is the whole of the + * one-room scope decision: a proposal naming a version this room does not hold + * fails loudly at apply time rather than landing as a row pointing at nothing. + * Cross-room projects are Phase 9 of docs/project-feature.md and start by taking + * these away, following `parentChatRoomId`'s precedent -- a plain column, never a + * foreign key, because a dangling value is the normal state there. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = MantraProject::class, + parentColumns = ["id"], + childColumns = ["projectId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = MantraArtifactVersion::class, + parentColumns = ["id"], + childColumns = ["artifactVersionId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + // Every read of this table is "this project's contents". + Index("projectId"), + // And the inverse, for an artifact version saying which projects it is in. + Index("artifactVersionId"), + Index("chatRoomId"), + ], +) +data class MantraProjectArtifactVersion( + @PrimaryKey + val id: HexKey, + val publicKey: HexKey, + + val projectId: HexKey, + val artifactVersionId: HexKey, + + val chatRoomId: String, + + /** Signature will be an empty string if this is a rumor */ + val signature: String, + + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + + /** The [GroupSignedEvent] this row was made from, when the group signed it. */ + val groupSignedEventId: HexKey? = null, + + /** Related nostrEventId if this is not a rumor... */ + override val nostrEventId: HexKey? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt +): OptionalNostrEventEntity, TimestampedEntity { + + companion object { + fun fromProjectArtifactVersionEventTemplate( + projectArtifactVersionEventTemplate: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraProjectArtifactVersion? { + return fromProjectArtifactVersionEvent( + ProjectArtifactVersionEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = projectArtifactVersionEventTemplate.tags, + content = projectArtifactVersionEventTemplate.content, + createdAt = projectArtifactVersionEventTemplate.createdAt, + kind = projectArtifactVersionEventTemplate.kind, + ), + content = projectArtifactVersionEventTemplate.content, + tags = projectArtifactVersionEventTemplate.tags, + createdAt = projectArtifactVersionEventTemplate.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + + fun fromProjectArtifactVersionEvent( + projectArtifactVersionEvent: ProjectArtifactVersionEvent, + chatRoomId: HexKey, + ): MantraProjectArtifactVersion? { + val projectId = projectArtifactVersionEvent.projectId() ?: return null + val artifactVersionId = + projectArtifactVersionEvent.artifactVersionId() ?: return null + + return MantraProjectArtifactVersion( + id = projectArtifactVersionEvent.id, + publicKey = projectArtifactVersionEvent.pubKey, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = chatRoomId, + signature = projectArtifactVersionEvent.sig, + createdAt = Instant.fromEpochSeconds(projectArtifactVersionEvent.createdAt), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectTranslationArtifactVersion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectTranslationArtifactVersion.kt new file mode 100644 index 00000000..b1fc7e5a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraProjectTranslationArtifactVersion.kt @@ -0,0 +1,123 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.TimestampedEntity +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * 30332 -- one translation is part of one project. + * + * The other half of [MantraProjectArtifactVersion]; see there for why both + * references are real foreign keys. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = MantraProject::class, + parentColumns = ["id"], + childColumns = ["projectId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = MantraTranslationArtifactVersion::class, + parentColumns = ["id"], + childColumns = ["translationArtifactVersionId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("projectId"), + Index("translationArtifactVersionId"), + Index("chatRoomId"), + ], +) +data class MantraProjectTranslationArtifactVersion( + @PrimaryKey + val id: HexKey, + val publicKey: HexKey, + + val projectId: HexKey, + val translationArtifactVersionId: HexKey, + + val chatRoomId: String, + + /** Signature will be an empty string if this is a rumor */ + val signature: String, + + val relayHint: String? = null, + val marmotGroupEventId: HexKey? = null, + + /** The [GroupSignedEvent] this row was made from, when the group signed it. */ + val groupSignedEventId: HexKey? = null, + + /** Related nostrEventId if this is not a rumor... */ + override val nostrEventId: HexKey? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt +): OptionalNostrEventEntity, TimestampedEntity { + + companion object { + fun fromProjectTranslationArtifactVersionEventTemplate( + template: EventTemplate, + chatRoomId: HexKey, + userPublicKey: HexKey, + ): MantraProjectTranslationArtifactVersion? { + return fromProjectTranslationArtifactVersionEvent( + ProjectTranslationArtifactVersionEvent( + id = EventHasher.hashId( + pubKey = userPublicKey, + tags = template.tags, + content = template.content, + createdAt = template.createdAt, + kind = template.kind, + ), + content = template.content, + tags = template.tags, + createdAt = template.createdAt, + pubKey = userPublicKey, + sig = "", // Unsigned rumor + ), + chatRoomId = chatRoomId, + ) + } + + fun fromProjectTranslationArtifactVersionEvent( + projectTranslationArtifactVersionEvent: ProjectTranslationArtifactVersionEvent, + chatRoomId: HexKey, + ): MantraProjectTranslationArtifactVersion? { + val projectId = + projectTranslationArtifactVersionEvent.projectId() ?: return null + val translationArtifactVersionId = + projectTranslationArtifactVersionEvent.translationArtifactVersionId() + ?: return null + + return MantraProjectTranslationArtifactVersion( + id = projectTranslationArtifactVersionEvent.id, + publicKey = projectTranslationArtifactVersionEvent.pubKey, + projectId = projectId, + translationArtifactVersionId = translationArtifactVersionId, + chatRoomId = chatRoomId, + signature = projectTranslationArtifactVersionEvent.sig, + createdAt = Instant.fromEpochSeconds( + projectTranslationArtifactVersionEvent.createdAt + ), + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt index eb5572f0..be17783a 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/RumorIdAgreementTest.kt @@ -10,6 +10,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag @@ -141,6 +144,49 @@ class RumorIdAgreementTest { assertEquals(rumorIdOf(template), entity?.id) } + @Test + fun `a project's row and its rumor agree`() { + val template = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters.", + creatorPublicKey = author, + ownerPublicKeys = setOf(other, "c".repeat(64)) + ) + + val entity = MantraProject.fromProjectEventTemplate( + projectEventTemplate = template, + chatRoomId = chatRoomId, + userPublicKey = author + ) + + assertEquals(rumorIdOf(template), entity?.id) + } + + @Test + fun `a project membership's row and its rumor agree`() { + val source = ProjectArtifactVersionEvent.build( + projectId = other, + artifactVersionId = "c".repeat(64) + ) + val translation = ProjectTranslationArtifactVersionEvent.build( + projectId = other, + translationArtifactVersionId = "c".repeat(64) + ) + + assertEquals( + rumorIdOf(source), + MantraProjectArtifactVersion + .fromProjectArtifactVersionEventTemplate(source, chatRoomId, author)?.id + ) + assertEquals( + rumorIdOf(translation), + MantraProjectTranslationArtifactVersion + .fromProjectTranslationArtifactVersionEventTemplate( + translation, chatRoomId, author + )?.id + ) + } + @Test fun `the submission names the id the row was written under`() { // The end of the chain the rest of this file checks a link of: what a diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt new file mode 100644 index 00000000..648d3d0b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt @@ -0,0 +1,355 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +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.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraProjectArtifactVersion +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.nostr.nip30303.ProjectEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The three tables, and the one guarantee the schema makes about them. + * + * A project's contents are foreign keys into this room's own rows. That is the + * scope decision -- one room, for now -- and it is load-bearing rather than + * decorative: it is what makes a membership naming a version this device does + * not hold fail at insert, loudly, instead of landing as a row pointing at + * nothing that every later read has to be defensive about. When projects learn + * to span rooms the keys go away, and this test is what has to be rewritten + * rather than quietly starting to pass for a different reason. + * + * The other thing here is that the owners are a string. A set stored as text is + * a shape that rots -- an empty entry, a stray space, the order changing between + * two devices -- so `ownerPublicKeySet` is tested against each of those rather + * than against the happy path it obviously handles. + */ +class MantraProjectDaoJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val roomId = "b".repeat(64) + private val otherRoomId = "f".repeat(64) + private val signature = "0".repeat(128) + + private val creator = "a".repeat(64) + private val ownerOne = "1".repeat(64) + private val ownerTwo = "2".repeat(64) + + private val artifactVersionId = "3".repeat(64) + private val translationId = "5".repeat(64) + + /** The room, an artifact version and a translation: the keys in the way. */ + private suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = creator, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert(Profile(publicKey = creator, nostrEventId = nostrEventId)) + + listOf(roomId, otherRoomId).forEach { id -> + db.chatRoomDao().upsert( + ChatRoom( + id = id, + userPublicKey = creator, + subject = "a translation room", + description = null, + mlsGroupState = null, + ) + ) + } + + val dialectId = "1".repeat(64) + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + + val artifactId = "2".repeat(64) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Sesotho", + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + } + + private suspend fun project( + id: String, + name: String = "Gospel of John", + chatRoomId: String = roomId, + owners: String = ownerOne, + ) = MantraProject( + id = id, + publicKey = chatRoomId, + name = name, + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = owners, + chatRoomId = chatRoomId, + signature = signature, + ).also { db.mantraProjectDao().upsert(it) } + + // --- the project table --- + + @Test + fun `a project round trips, owners and creator intact`() = runBlocking { + seed() + project(id = "p".repeat(64), owners = "$ownerOne,$ownerTwo") + + val stored = db.mantraProjectDao().getProjectById("p".repeat(64)) + ?: fail("the project was not stored") + + assertEquals(creator, stored.creatorPublicKey) + assertEquals(setOf(ownerOne, ownerTwo), stored.ownerPublicKeySet()) + assertTrue(stored.isOwnedBy(ownerTwo)) + assertTrue(!stored.isOwnedBy(creator)) + } + + @Test + fun `projects are listed per room, by name`() = runBlocking { + seed() + project(id = "p2".padEnd(64, '0'), name = "Zechariah") + project(id = "p1".padEnd(64, '0'), name = "Amos") + project(id = "p3".padEnd(64, '0'), name = "Elsewhere", chatRoomId = otherRoomId) + + val names = db.mantraProjectDao().getProjectsByChatRoomId(roomId).map { it.name } + + assertEquals(listOf("Amos", "Zechariah"), names) + } + + // --- ownerPublicKeySet, against the ways a delimited string goes wrong --- + + @Test + fun `the owner set survives spacing, empties and a single entry`() { + fun owners(stored: String) = MantraProject( + id = "p".repeat(64), + publicKey = roomId, + name = "n", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = stored, + chatRoomId = roomId, + signature = signature, + ).ownerPublicKeySet() + + assertEquals(setOf(ownerOne), owners(ownerOne)) + assertEquals(setOf(ownerOne, ownerTwo), owners(" $ownerOne , $ownerTwo ")) + assertEquals(setOf(ownerOne), owners("$ownerOne,,")) + assertEquals(emptySet(), owners("")) + } + + @Test + fun `two rows built from the same owners in different orders agree`() { + val event = { owners: Set -> + ProjectEvent.build( + name = "Gospel of John", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = owners, + createdAt = 1_000, + ) + } + + val one = MantraProject.fromProjectEventTemplate( + event(linkedSetOf(ownerTwo, ownerOne)), roomId, roomId + ) + val other = MantraProject.fromProjectEventTemplate( + event(linkedSetOf(ownerOne, ownerTwo)), roomId, roomId + ) + + assertEquals(one?.ownerPublicKeys, other?.ownerPublicKeys) + // And the ids too, since the event sorts before hashing. + assertEquals(one?.id, other?.id) + } + + // --- the memberships, and the keys that make a bad one fail --- + + @Test + fun `a project's contents come back resolved`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId) + + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = roomId, + signature = signature, + ) + ) + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + translationArtifactVersionId = translationId, + chatRoomId = roomId, + signature = signature, + ) + ) + + val sources = db.mantraProjectArtifactVersionDao() + .getArtifactVersionsByProjectId(projectId) + val translations = db.mantraProjectTranslationArtifactVersionDao() + .getTranslationArtifactVersionsByProjectId(projectId) + + assertEquals(listOf("1.0"), sources.map { it.versionLabel }) + assertEquals(listOf("Sesotho"), translations.map { it.name }) + } + + @Test + fun `an artifact version says which projects it is in`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId, name = "Gospel of John") + + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = roomId, + signature = signature, + ) + ) + + assertEquals( + listOf("Gospel of John"), + db.mantraProjectArtifactVersionDao() + .getProjectsByArtifactVersionId(artifactVersionId) + .map { it.name } + ) + } + + @Test + fun `a membership naming a version this room does not hold is refused`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId) + + val absent = "9".repeat(64) + + val failure = runCatching { + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = absent, + chatRoomId = roomId, + signature = signature, + ) + ) + }.exceptionOrNull() + + // The foreign key, not a check in the apply arm. When Phase 9 takes it + // away this is the assertion that has to be rewritten rather than left + // to start passing because nothing writes memberships any more. + assertTrue( + failure != null, + "a membership pointing at an absent version was accepted" + ) + assertNull(db.mantraProjectArtifactVersionDao().getById("m1".padEnd(64, '0'))) + } + + @Test + fun `deleting the room takes the project and its memberships with it`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId) + + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + artifactVersionId = artifactVersionId, + chatRoomId = roomId, + signature = signature, + ) + ) + + db.chatRoomDao().delete( + ChatRoom( + id = roomId, + userPublicKey = creator, + subject = "a translation room", + description = null, + mlsGroupState = null, + ) + ) + + assertNull(db.mantraProjectDao().getProjectById(projectId)) + assertTrue(db.mantraProjectArtifactVersionDao().getByProjectId(projectId).isEmpty()) + } +} From 6e97735e470f66f5fdc68f6f7e2de20396c49959 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:12:30 +0200 Subject: [PATCH 08/32] feat(projects): apply a signed project, and refuse the three ways it can lie Phase 3 of docs/project-feature.md. Three arms in `ChatMessage.applyInnerEvent`, which is where every nip30303 kind lands whether it arrived over the wire or was signed locally -- `FrostSigningManager` reuses this dispatch rather than repeating it. A project can now become a row; nothing proposes one yet. The project writes a chat line naming the project and not its creator. On a group-signed row `senderPublicKey` is the session's coordinator, so "X started the project" would be the transcript guessing at something the event states exactly. The memberships write no line, the way a version writes none beside its artifact: they arrive as later items of the batch that founds the project, which has already said so, and one added later is covered by the "The group signed ..." line `FrostSigningManager.announce` already writes. `fromProjectEvent` refuses an event with no name, no creator or no owners. The third refusal is the one worth having a test for and it is not about malformed events at all: it is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES` looks like from here. The role parses as null, `owners()` comes back empty, the signature still verifies and nothing is logged, so the refusal is the alarm. `ProjectApplyJvmTest` reproduces it with a role deliberately not in the set. A membership whose version or project this device does not hold is refused by the foreign key, and the refusal is caught here rather than left to escape. `applySignedEvent` swallows throwables because the signature is valid either way, so an escaping one would report a session that succeeded as a session that failed and tell the group to abandon a ceremony whose signature is good. No call path wraps `applyInnerEvent` in a transaction -- checked, all three -- so catching it does not poison one. 1381 tests, no failures. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 133 +++++++ .../database/model/ProjectApplyJvmTest.kt | 368 ++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index efa2b704..944536ce 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -37,6 +37,9 @@ import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributor import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationEvent import co.touchlab.kermit.Logger @@ -1270,6 +1273,136 @@ data class ChatMessage( ) } } + // A body of work the group has named: which source versions it + // is translating and which translations it is producing. + // + // The memberships are their own events, signed in the same + // session and applied after this one -- see + // AddProjectViewModel.addProject. Nothing to do here but the + // project itself. + // + // The line names the project and not its creator. On a + // group-signed row `senderPublicKey` is the session's + // coordinator, so "X started the project" would be the + // transcript's own guess at something the event states exactly; + // the creator and the owners are on the row, and the detail + // screen is where they are read. + ProjectEvent.KIND -> { + MantraProject.fromProjectEvent( + projectEvent = ProjectEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { mantraProject -> + database.mantraProjectDao().upsert( + mantraProject.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + + ChatMessage( + giftWrapPayloadId = null, + messageType = "project", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "Started the ${mantraProject.name} project" + ) + } + } + + // One source version is part of one project. + // + // No line of its own, the way a version writes none beside its + // artifact and a chunk none beside its chapter: a membership + // arrives as a later item of the batch that founds the project, + // which has already said so. One added to an existing project + // later is not silent either -- `FrostSigningManager.announce` + // writes "The group signed ..." when the session completes, and + // three more lines here would be a second account of it. + // + // The row may be refused: both references are foreign keys, so a + // membership naming a version this device does not hold cannot be + // written. That is the one-room scope decision doing its job + // rather than an error to propagate -- `applySignedEvent` + // swallows throwables because the signature is valid either way, + // and letting this one reach it would report a session that + // succeeded as a session that failed. + ProjectArtifactVersionEvent.KIND -> { + MantraProjectArtifactVersion.fromProjectArtifactVersionEvent( + projectArtifactVersionEvent = ProjectArtifactVersionEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { membership -> + try { + database.mantraProjectArtifactVersionDao().upsert( + membership.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + } catch (error: Throwable) { + logger.w( + "Project ${membership.projectId.take(8)} names artifact " + + "version ${membership.artifactVersionId.take(8)}, which " + + "this device does not hold", + error + ) + } + } + null + } + + // The other half. See the arm above. + ProjectTranslationArtifactVersionEvent.KIND -> { + MantraProjectTranslationArtifactVersion + .fromProjectTranslationArtifactVersionEvent( + projectTranslationArtifactVersionEvent = + ProjectTranslationArtifactVersionEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), + chatRoomId = groupId, + )?.let { membership -> + try { + database.mantraProjectTranslationArtifactVersionDao().upsert( + membership.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, + ) + ) + } catch (error: Throwable) { + logger.w( + "Project ${membership.projectId.take(8)} names " + + "translation " + + membership.translationArtifactVersionId.take(8) + + ", which this device does not hold", + error + ) + } + } + null + } + // A signing session's own protocol messages. FrostSigningManager // applies them and writes its own transcript lines naming who did // what, so an "unsupported" row here would be a second, worse diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt new file mode 100644 index 00000000..1d90b48a --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt @@ -0,0 +1,368 @@ +package press.mantra.compose.database.model + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +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 kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import press.mantra.compose.nostr.nip30303.tags.NameTag +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Instant + +/** + * What a signed project turns into, and the three ways it is allowed not to. + * + * The apply arm is the only place a project row is written -- `FrostSigningManager` + * reuses this dispatch rather than repeating it -- so it is also the only place + * the event's claims about people become columns. Two of those claims are + * required and the arm refuses the event rather than inventing them, which + * matters more than it looks: the whole reason the creator and the owners are + * tags is that they are inside the id the quorum signed, and a row that could + * have been filled in from local knowledge would be worth less than no row. + * + * The third refusal is the owners disappearing while the event stays valid. + * That is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES` looks + * like from here -- the role parses as null and `owners()` comes back empty -- + * and the only thing standing between that and a silent regression is this + * test, since nothing logs and the signature still verifies. + */ +class ProjectApplyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val roomId = "b".repeat(64) + private val signature = "0".repeat(128) + + private val creator = "a".repeat(64) + private val ownerOne = "1".repeat(64) + private val ownerTwo = "2".repeat(64) + private val ownerThree = "4".repeat(64) + + private val artifactVersionId = "3".repeat(64) + private val translationId = "5".repeat(64) + + private suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = creator, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert(Profile(publicKey = creator, nostrEventId = nostrEventId)) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = creator, + subject = "a translation room", + description = null, + mlsGroupState = null, + ) + ) + + val dialectId = "1".repeat(64) + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + + val artifactId = "2".repeat(64) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + signature = signature, + chatRoomId = roomId, + ) + ) + + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Sesotho", + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + } + + /** The event as the group signed it: authored by the room, id over its tags. */ + private fun signed(template: EventTemplate<*>): Event = Event( + id = EventHasher.hashId( + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature, + ) + + private suspend fun apply(event: Event) = ChatMessage.applyInnerEvent( + database = db, + groupId = roomId, + event = event, + marmotGroupEventId = null, + marmotInnerEventId = null, + senderPublicKey = roomId, + isUserMessage = false, + createdAt = Instant.fromEpochSeconds(event.createdAt), + ) + + private fun projectTemplate(owners: Set = setOf(ownerOne)) = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = owners, + createdAt = 1_000, + ) + + // --- the project --- + + @Test + fun `a signed project becomes a row carrying its creator and every owner`() = runBlocking { + seed() + val project = signed(projectTemplate(setOf(ownerThree, ownerOne, ownerTwo))) + + val line = apply(project) ?: fail("a project writes a chat line") + + val stored = db.mantraProjectDao().getProjectById(project.id) + ?: fail("the project was not stored") + + assertEquals("Gospel of John into Sesotho", stored.name) + assertEquals("The four chapters.", stored.description) + assertEquals(creator, stored.creatorPublicKey) + assertEquals(setOf(ownerOne, ownerTwo, ownerThree), stored.ownerPublicKeySet()) + // The room authored it; the creator is a claim it carries, not its author. + assertEquals(roomId, stored.publicKey) + + assertEquals("project", line.messageType) + assertTrue(line.content.contains("Gospel of John into Sesotho")) + } + + @Test + fun `a project with no creator tag writes nothing`() = runBlocking { + seed() + + // Everything a project has except the creator, with the tag order the + // builder uses so this is a plausible event rather than a mangled one. + val template = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = setOf(ownerOne), + createdAt = 1_000, + ) + val withoutCreator = Event( + id = "c".repeat(64), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags.filterNot { it.firstOrNull() == CreatorTag.TAG_NAME } + .toTypedArray(), + content = template.content, + sig = signature, + ) + + assertNull(apply(withoutCreator)) + assertNull(db.mantraProjectDao().getProjectById(withoutCreator.id)) + } + + /** + * The regression this file exists for. + * + * An unregistered role is dropped by `ContributorTag.parse`, so the owner + * tags are still there, the event still verifies, and `owners()` returns + * nothing. Built by hand with a role that is deliberately not in + * `KNOWN_ROLES`, because the point is to reproduce what `ROLE_OWNER` would + * look like if somebody removed it. + */ + @Test + fun `a project whose owner role is not a known one writes nothing`() = runBlocking { + seed() + + val tags = TagArrayBuilder() + .addUnique(AltTag.assemble(ProjectEvent.ALT_DESCRIPTION)) + .addUnique(NameTag.assemble("Gospel of John into Sesotho")) + .addUnique(CreatorTag.assemble(creator)) + .addUniqueValueIfNew(ContributorTag.assemble(ownerOne, "proprietor")) + .build() + + val event = Event( + id = "d".repeat(64), + pubKey = roomId, + createdAt = 1_000, + kind = ProjectEvent.KIND, + tags = tags, + content = "The four chapters.", + sig = signature, + ) + + assertNull(apply(event)) + assertNull(db.mantraProjectDao().getProjectById(event.id)) + } + + // --- the memberships --- + + @Test + fun `memberships are stored and write no line of their own`() = runBlocking { + seed() + val project = signed(projectTemplate()) + apply(project) + + val source = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(artifactVersionId)).single() + ) + val translation = signed( + ProjectTranslationArtifactVersionEvent + .membershipsOf(project, listOf(translationId)) + .single() + ) + + // Null, the way a version writes none beside its artifact: the project's + // own line has already said what happened. + assertNull(apply(source)) + assertNull(apply(translation)) + + assertEquals( + listOf("1.0"), + db.mantraProjectArtifactVersionDao() + .getArtifactVersionsByProjectId(project.id) + .map { it.versionLabel } + ) + assertEquals( + listOf("Sesotho"), + db.mantraProjectTranslationArtifactVersionDao() + .getTranslationArtifactVersionsByProjectId(project.id) + .map { it.name } + ) + } + + @Test + fun `a membership naming a version this device lacks leaves the project standing`() = + runBlocking { + seed() + val project = signed(projectTemplate()) + apply(project) + + val absent = "9".repeat(64) + val source = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(absent)).single() + ) + + // The foreign key refuses it. What must not happen is the refusal + // escaping: `applySignedEvent` would report a session that succeeded + // as one that failed, and the group would be told to abandon a + // ceremony whose signature is perfectly good. + assertNull(apply(source)) + + assertTrue( + db.mantraProjectArtifactVersionDao().getByProjectId(project.id).isEmpty() + ) + assertEquals( + "Gospel of John into Sesotho", + db.mantraProjectDao().getProjectById(project.id)?.name + ) + } + + @Test + fun `a membership naming a project this device lacks is refused the same way`() = + runBlocking { + seed() + val absentProject = "8".repeat(64) + + val template = ProjectArtifactVersionEvent.build( + projectId = absentProject, + artifactVersionId = artifactVersionId, + createdAt = 1_000, + ) + + assertNull(apply(signed(template))) + assertTrue( + db.mantraProjectArtifactVersionDao().getByProjectId(absentProject).isEmpty() + ) + } + + @Test + fun `a membership missing the project it belongs to writes nothing`() = runBlocking { + seed() + val project = signed(projectTemplate()) + apply(project) + + val template = ProjectArtifactVersionEvent.build( + projectId = project.id, + artifactVersionId = artifactVersionId, + createdAt = 1_000, + ) + val withoutProject = Event( + id = "7".repeat(64), + pubKey = roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags.filterNot { it.firstOrNull() == ProjectIdTag.TAG_NAME } + .toTypedArray(), + content = template.content, + sig = signature, + ) + + assertNull(apply(withoutProject)) + assertNull(db.mantraProjectArtifactVersionDao().getById(withoutProject.id)) + } +} From 2f8df359a809f70dff9ffe22219c362a07f465f8 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:12:34 +0200 Subject: [PATCH 09/32] feat(chapters): upload several files, one proposal each Phase 4 of docs/chapter-uploads.md: the feature. A review step rather than a submit button, and that is the one place this departs visibly from the web app it mirrors. mantra.press can post twelve files and redirect, because what it creates belongs to the uploader and the uploader can fix it. Here each file becomes a request for the group's own signature, so a filename standing in for a title, a file that decoded to nothing, or a chapter too long to carry is wrong in something a quorum is about to put its name to. Picking files produces a list; the list is what is proposed. Per-row problems, because a bad file should not cost the other eleven: not text, too large, nothing to translate, no name, more chunks than a session signs, more bytes than a proposal carries -- and a duplicate, which warns rather than blocks. A book can repeat a page, but the common cause is picking the same directory twice, and two chapters with the same text are two event ids nothing later can merge. Proposals go out sequentially, never concurrently: each writes a session and publishes group events, and a group event that arrives a moment out of order is dropped for good (docs/mls-skipped-keys.md). Indices are allocated once from a freshly read base, so numbering does not depend on how fast the sessions are written. Partial success is the expected outcome and not an error. Nothing can retract a proposal, so if the fourth fails the first three are real and the group can still sign them: no rollback, no navigating away, the rows that went are marked and the rest keep their reason, which is the only place a retry can be aimed from. A pass that fully succeeds lands on the proposal list rather than a signing screen -- at N there is no one session to watch. Two shapes the writing settled. UploadReview holds the row building and the problem decisions, which are pure functions of the files, the names typed over them and the chapters the version already has; the view model keeps only the orchestration, and the 13 tests need no view-model harness. And uiState is a StateFlow, not `by mutableStateOf`: initiate() writes from Dispatchers.IO after a primary-key lookup, which is exactly the few milliseconds in which a write from another thread into a composition's snapshot is silently discarded -- the bug that once left this app on "Decrypting..." for ever. Scaffolding into translations is not here yet; Phase 5 lifts it out of AddChapterViewModel so one batch covers every chapter rather than one session each. Until then an uploaded chapter reaches a translation through that translation's own catch-up. m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 22 + .../ui/composable/ArtifactDetailScreen.kt | 76 ++- .../ui/composable/UploadChaptersScreen.kt | 559 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 29 + .../navigation/routes/UploadChaptersRoute.kt | 19 + .../ui/view/model/UploadChaptersViewModel.kt | 332 +++++++++++ .../ui/view/state/UploadChaptersUIState.kt | 165 ++++++ .../compose/ui/view/state/UploadReview.kt | 121 ++++ .../compose/ui/view/state/UploadReviewTest.kt | 180 ++++++ 9 files changed, 1485 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 161e370e..8a55b6cf 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -501,4 +501,26 @@ %1$d words · %2$d characters · %3$s · %4$d of %5$d chunks %1$s of %2$s %1$s KiB + Upload chapters + Upload chapters to %1$s + Choose files + Add more files + No files picked yet. + Every file becomes its own proposal, and each one needs a quorum of the group to sign it. Twelve files is twelve things every other member is asked to read and approve. + At most %1$d files in one upload. + Propose %1$d chapters + Propose the remaining %1$d + %1$d proposed, %2$d failed. + %1$d chapters proposed. + Chapter %1$d of the version + Waiting for the group to sign + Remove from this upload + Not text. A chapter is markdown or plain text. + Too large to be a chapter. + Nothing in it to translate. + Give this chapter a name. + %1$d chunks, and the group signs at most %2$d in one go. Split this file. + %1$s, and a group message carries %2$s. Split this file. + The same text as %1$s. Proposing both makes two chapters. + Proposing this one failed. Try again. 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 b8fd9da9..06c128fd 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 @@ -1,6 +1,7 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -36,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.widgets.DetailRow +import androidx.compose.material.icons.filled.UploadFile +import mantra.composeapp.generated.resources.upload_chapters import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute +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 @@ -162,25 +166,61 @@ fun ArtifactDetailScreen( } item { - TextButton( - enabled = artifactDetailUIState.versions.isNotEmpty(), - onClick = { - onNavigateToRoute.invoke( - AddChapterRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.PostAdd, - contentDescription = "Add chapter" + // Two ways in, because they are two different acts. + // One chapter typed or pasted is a text field and one + // proposal; a folder of files is a review list and one + // proposal each, which is a different thing to put in + // front of the room. + Row( + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap ) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - Text(stringResource(Res.string.add_chapter)) + ) { + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), + onClick = { + onNavigateToRoute.invoke( + AddChapterRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.PostAdd, + contentDescription = "Add chapter" + ) + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text(stringResource(Res.string.add_chapter)) + } + + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), + onClick = { + onNavigateToRoute.invoke( + UploadChaptersRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.UploadFile, + contentDescription = "Upload chapters from files" + ) + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text(stringResource(Res.string.upload_chapters)) + } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt new file mode 100644 index 00000000..23ae2785 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreen.kt @@ -0,0 +1,559 @@ +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.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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_more_files +import mantra.composeapp.generated.resources.at_most_d_files_in_one_upload +import mantra.composeapp.generated.resources.chapter_d_of_the_version +import mantra.composeapp.generated.resources.chapter_name +import mantra.composeapp.generated.resources.choose_chapter_files +import mantra.composeapp.generated.resources.choose_files +import mantra.composeapp.generated.resources.d_chapters_proposed +import mantra.composeapp.generated.resources.d_chunks_is_too_many_to_sign +import mantra.composeapp.generated.resources.d_proposed_d_failed +import mantra.composeapp.generated.resources.no_files_picked_yet +import mantra.composeapp.generated.resources.one_proposal_per_file +import mantra.composeapp.generated.resources.propose_d_chapters +import mantra.composeapp.generated.resources.propose_the_remaining_d +import mantra.composeapp.generated.resources.proposing_this_one_failed +import mantra.composeapp.generated.resources.remove_from_this_upload +import mantra.composeapp.generated.resources.s_is_more_than_a_proposal_carries +import mantra.composeapp.generated.resources.s_kib +import mantra.composeapp.generated.resources.same_text_as_s +import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter +import mantra.composeapp.generated.resources.this_file_has_no_name +import mantra.composeapp.generated.resources.this_file_is_empty +import mantra.composeapp.generated.resources.this_file_is_not_text +import mantra.composeapp.generated.resources.this_file_is_too_large +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_for_a_chapter +import mantra.composeapp.generated.resources.upload_chapters_to +import mantra.composeapp.generated.resources.waiting_for_the_group_to_sign +import mantra.composeapp.generated.resources.words_characters +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.files.rememberFilePicker +import press.mantra.compose.nostr.nip30303.ChapterProposal +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.kibibytes +import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +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.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab +import press.mantra.compose.ui.composable.widgets.rememberNotifier +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.UploadChaptersViewModel +import press.mantra.compose.ui.view.state.PickedChapter +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadProblem +import press.mantra.compose.ui.view.state.UploadReview + +/** + * Picking files, reviewing what they would become, and putting each to the + * group. + * + * **A review step, not a submit button**, and that is where this departs + * visibly from the web app it mirrors. mantra.press can afford to post twelve + * files and redirect, because what it creates belongs to the uploader and the + * uploader can fix it. Here each file becomes a request for the group's own + * signature, so a filename standing in for a title, a file that decoded to + * nothing, or a chapter too long to carry is wrong in something a quorum is + * about to put its name to. Picking files therefore produces a list, and the + * list is what is proposed. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UploadChaptersScreen( + activeUserPublicKey: HexKey, + artifactId: String, + chatRoomId: String, + relayHint: String?, + initialUIState: UploadChaptersUIState = UploadChaptersUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val viewModel: UploadChaptersViewModel = viewModel( + factory = UploadChaptersViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifactId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialUIState = initialUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + ScreenStateTransition(uiState) { state -> + when (state) { + UploadChaptersUIState.Loading -> LoadingDataIndicator(fillScreen = true) + + is UploadChaptersUIState.Error -> + ErrorState( + message = state.message, + onRetry = { viewModel.initiate() }, + ) + + is UploadChaptersUIState.Loaded -> { + val notify = rememberNotifier(rememberCoroutineScope()) + + // The sentence is built in composition and shown from an effect, + // because `stringResource` is composable and the propose + // callback is not. Formatting it by hand in the callback was the + // first draft and meant doing a resource's own job with + // `replaceFirst`. + val outcomeMessage = state.lastOutcome?.let { outcome -> + if (outcome.failed > 0) { + stringResource( + Res.string.d_proposed_d_failed, + outcome.proposed, + outcome.failed, + ) + } else { + stringResource(Res.string.d_chapters_proposed, outcome.proposed) + } + } + + LaunchedEffect(state.lastOutcome?.pass) { + outcomeMessage?.let(notify) + } + + val picker = rememberFilePicker( + title = stringResource(Res.string.choose_chapter_files) + ) { files -> viewModel.addFiles(files) } + + val version = state.artifactVersion + + // Where each row lands, counted over the rows not yet proposed: + // a proposed chapter has taken its number and the next one does + // not take it again. + val offsets = remember(state.chapters) { + var next = 0 + buildMap { + state.chapters.forEach { chapter -> + if (chapter.sessionId == null) put(chapter.id, next++) + } + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + // A name field per row, so the keyboard must not cover the + // row being named. + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + Text( + stringResource( + Res.string.upload_chapters_to, + state.artifact.name, + ) + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + val remaining = state.proposable.size + val isRetry = state.proposed.isNotEmpty() + + ExtendedFab( + label = if (isRetry) { + stringResource(Res.string.propose_the_remaining_d, remaining) + } else { + stringResource(Res.string.propose_d_chapters, remaining) + }, + icon = { + Icon( + Icons.Default.UploadFile, + contentDescription = "Propose these chapters" + ) + }, + enabled = state.canPropose, + onClick = { + if (version == null) return@ExtendedFab + + viewModel.propose( + localChatRoom = state.localChatRoom, + artifactVersion = version, + ) { proposed, failed -> + // Leaves only when there is nothing + // left to aim a retry from. On a + // partial pass the screen stays: the + // rows that went are marked and the + // rest keep their reason, and that + // list is the only place a retry can + // be aimed from. + if (failed == 0 && proposed > 0) { + // The proposal list, not a + // signing screen: at N there is + // no one session to watch, and + // the list is the screen that + // exists for showing several. + onNavigateToRouteAndPopUpInclusive( + ProposalListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + ) + ) + } + } + } + ) + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + item { + // Said before the picker opens, not after. An upload + // spends the room's attention, and the person + // picking files is the only one who can weigh that. + Text( + text = stringResource(Res.string.one_proposal_per_file), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!state.canSign) { + item { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_for_a_chapter + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } else if (version == null) { + item { + Text( + text = stringResource( + Res.string.this_artifact_has_no_version_for_a_chapter + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + enabled = state.chapters.size < + UploadChaptersViewModel.MAX_FILES_PER_UPLOAD, + onClick = { picker.launch(multiple = true) } + ) { + Icon( + Icons.Default.UploadFile, + contentDescription = "Choose files to upload" + ) + Text( + modifier = Modifier.padding( + start = MaterialTheme.spacing.itemGap + ), + text = if (state.chapters.isEmpty()) { + stringResource(Res.string.choose_files) + } else { + stringResource(Res.string.add_more_files) + } + ) + } + + Text( + text = stringResource( + Res.string.at_most_d_files_in_one_upload, + UploadChaptersViewModel.MAX_FILES_PER_UPLOAD, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (state.chapters.isEmpty()) { + item { + EmptyState( + message = stringResource(Res.string.no_files_picked_yet), + icon = Icons.Default.UploadFile, + ) + } + } + + items(items = state.chapters, key = { it.id }) { chapter -> + PickedChapterCard( + chapter = chapter, + index = state.baseIndex + (offsets[chapter.id] ?: 0), + onRename = { viewModel.rename(chapter.id, it) }, + onRemove = { viewModel.remove(chapter.id) }, + ) + } + } + } + } + } + } + + LaunchedEffect(true) { + if (initialUIState == UploadChaptersUIState.Loading) viewModel.initiate() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PickedChapterCard( + chapter: PickedChapter, + index: Int, + onRename: (String) -> Unit, + onRemove: () -> Unit, +) { + val proposed = chapter.sessionId != null + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.compactPadding) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = chapter.fileName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (proposed) { + stringResource(Res.string.waiting_for_the_group_to_sign) + } else { + stringResource(Res.string.chapter_d_of_the_version, index) + }, + style = MaterialTheme.typography.labelMedium + ) + } + + if (proposed) { + Icon( + Icons.Default.HourglassTop, + contentDescription = "Waiting for the group to sign" + ) + } else { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = stringResource( + Res.string.remove_from_this_upload + ) + ) + } + } + } + + if (!proposed) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = chapter.name, + onValueChange = onRename, + isError = chapter.problem == UploadProblem.Unnamed, + label = { Text(stringResource(Res.string.chapter_name)) }, + singleLine = true, + ) + } + + Text( + text = stringResource( + Res.string.words_characters, + chapter.wordCount, + chapter.characterCount, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + chapter.problem?.let { problem -> + Text( + text = problem.describe(), + style = MaterialTheme.typography.bodySmall, + // A warning is not an error. A duplicate is a thing worth + // saying and not a thing that stops the upload, and giving + // the two one colour would make the second look like the + // first. + color = if (problem.blocking) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + } +} + +@Composable +private fun UploadProblem.describe(): String = when (this) { + UploadProblem.NotText -> stringResource(Res.string.this_file_is_not_text) + UploadProblem.TooLarge -> stringResource(Res.string.this_file_is_too_large) + UploadProblem.Empty -> stringResource(Res.string.this_file_is_empty) + UploadProblem.Unnamed -> stringResource(Res.string.this_file_has_no_name) + UploadProblem.ProposalFailed -> stringResource(Res.string.proposing_this_one_failed) + is UploadProblem.TooManyChunks -> stringResource( + Res.string.d_chunks_is_too_many_to_sign, + count, + UploadReview.MAX_CHUNKS_PER_CHAPTER, + ) + is UploadProblem.TooLargeToPropose -> stringResource( + Res.string.s_is_more_than_a_proposal_carries, + stringResource(Res.string.s_kib, kibibytes(bytes)), + stringResource(Res.string.s_kib, kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES)), + ) + is UploadProblem.Duplicate -> stringResource(Res.string.same_text_as_s, of) +} + +@ConformancePreviews +@Composable +private fun UploadChaptersScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + UploadChaptersScreen( + activeUserPublicKey = "", + artifactId = "artifactId", + chatRoomId = "chatRoomId", + relayHint = null, + initialUIState = UploadChaptersUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "", + subject = "Message title", + description = "See something. Say somethin", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ), + ), + artifact = MantraArtifact( + id = "artifactId", + publicKey = "author", + name = "To Kill a Mockingbird", + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = "dialectId", + license = "cc", + chatRoomId = "chatRoomId", + signature = "" + ), + artifactVersion = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "chatRoomId", + signature = "" + ), + canSign = true, + baseIndex = 3, + chapters = listOf( + PickedChapter( + id = "0:01-the-wreck.md", + fileName = "01-the-wreck.md", + name = "The wreck", + markdown = "One.\n\nTwo.", + chunks = listOf("One.", "Two."), + wordCount = 2, + characterCount = 10, + proposalBytes = 1400, + ), + PickedChapter( + id = "1:02-cover.png", + fileName = "02-cover.png", + name = "", + markdown = "", + chunks = emptyList(), + wordCount = 0, + characterCount = 0, + proposalBytes = 448, + problem = UploadProblem.NotText, + ), + ), + ), + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + 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 92f022d0..de29594f 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 @@ -140,6 +140,7 @@ import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSigned import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute +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.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute @@ -148,6 +149,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.ArtifactDetailScreen 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.FrostSigningScreen import press.mantra.compose.ui.composable.ProposalListScreen @@ -1334,6 +1336,33 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + UploadChaptersScreen( + activeUserPublicKey = route.activeUserPublicKey, + artifactId = route.artifactId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { listRoute -> + // Replace this screen, so back returns to the artifact + // rather than to a review list whose proposals have + // already gone out. + navController.navigate(route = listRoute) { + popUpTo { + inclusive = true + } + } + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt new file mode 100644 index 00000000..e3dd24ec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/UploadChaptersRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Several files at once, each becoming a chapter the group is asked to sign. + * + * The same arguments as [AddChapterRoute], because it is the same act at a + * different scale -- and deliberately a separate destination rather than a mode + * on that one. The add form is a text field and one proposal; this is a review + * list and N of them, with a different thing to say before it is used. + */ +@Serializable +data class UploadChaptersRoute( + val activeUserPublicKey: String, + val artifactId: String, + val chatRoomId: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt new file mode 100644 index 00000000..6ea233e5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt @@ -0,0 +1,332 @@ +package press.mantra.compose.ui.view.model + +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.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.files.PickedFile +import press.mantra.compose.managers.ChapterIndexing +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown +import press.mantra.compose.ui.view.state.PickedChapter +import press.mantra.compose.ui.view.state.ProposeOutcome +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadReview +import press.mantra.compose.ui.view.state.UploadProblem + +/** + * Several files in, one signing session per chapter out. + * + * **Why not one session for the lot.** The web app this mirrors does N files in + * one transaction; a batch cannot. A session signs at most + * [FrostSigningManager.MAX_BATCH_SIZE] events and a chapter spends one place + * with its paragraphs taking the rest, so two chapters of thirty paragraphs + * already overflow. Worse, a batch is all-or-nothing: one chapter that failed to + * reach a quorum would take the other eleven with it, and a book is precisely a + * set of things that do not need to arrive together. And the approval gate's own + * rule is that it shows every event rather than a count, so twelve chapters in + * one gate would be a worse prompt than the twelve it replaced. + * + * So an upload is N proposals, each needing its own quorum. That is a real + * imposition on every other member of the room, and the only person in a + * position to weigh it is the one picking files -- which is why the screen says + * so before the picker opens, and why [MAX_FILES_PER_UPLOAD] exists. + * + * **Partial success is the expected outcome, not an error.** Nothing can retract + * a proposal, so if the fourth fails the first three are real and the group can + * still sign them. There is no rollback and no navigating away on failure: the + * rows that went are marked, the rest keep their reason, and the button becomes + * a retry. + */ +class UploadChaptersViewModel( + val artifactId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialUIState: UploadChaptersUIState, + val mantraRepository: MantraRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +) : ViewModel() { + + /** + * A `StateFlow` rather than `by mutableStateOf`, and not as a matter of + * taste. + * + * View models here are built by `viewModel(factory = ...)` *inside* + * composition, so a `mutableStateOf` created in a constructor belongs to + * that composition's snapshot -- and a write from another thread that lands + * before the composition is applied is silently discarded. No exception, no + * log; the state keeps its initial value for the life of the process. + * + * [initiate] writes from `Dispatchers.IO`, and the reads it does are a + * primary-key lookup and a `canSign` check: a few milliseconds, which is + * exactly the width of that window. The failure would be this screen sitting + * on its loading indicator for ever on a fast device while a slow one worked. + * That bug has already been had here once; see + * `SovereignWalletViewModel.listWalletState`. + */ + private val _uiState = MutableStateFlow(initialUIState) + val uiState: StateFlow = _uiState.asStateFlow() + + private val logger = Logger.withTag(TAG) + + /** + * The text of every chapter this version already has, against its name. + * + * Held so a picked file can be recognised as one that is already in the + * version. The whole artifact's prose in memory is not free, and it is the + * price of answering the question at all: two chapters with the same text + * are two different event ids, and nothing later can merge them. + */ + private var existingByText: Map = emptyMap() + + /** Counts passes, so two with the same outcome are still two announcements. */ + private var passes: Int = 0 + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val artifact = mantraRepository.getArtifact(artifactId) + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + _uiState.value = when { + artifact == null -> UploadChaptersUIState.Error("Couldn't find the artifact") + localChatRoom == null -> UploadChaptersUIState.Error("Couldn't find the chat room") + else -> { + val version = mantraRepository.getArtifactVersions(artifactId).firstOrNull() + + existingByText = version?.let { v -> + mantraRepository.getChaptersForArtifactVersion(v.id) + .associate { it.originalText to it.name } + }.orEmpty() + + UploadChaptersUIState.Loaded( + localChatRoom = localChatRoom, + artifact = artifact, + artifactVersion = version, + canSign = frostSigningRepository.canSign(chatRoomId), + baseIndex = version?.let { nextIndex(it) } ?: 0, + ) + } + } + } + } + + /** + * Decodes picked files into rows, appending to whatever is already listed. + * + * Appending rather than replacing, so a second visit to the picker adds to + * the upload instead of discarding it -- a reader who meant to replace can + * remove rows, and one who meant to add cannot undo a replacement. + */ + fun addFiles(files: List) { + if (files.isEmpty()) return + + updateLoaded { loaded -> + val room = loaded.chapters.size + val added = UploadReview.rows( + files = files.take((MAX_FILES_PER_UPLOAD - room).coerceAtLeast(0)), + startOrdinal = room, + ) + + loaded.copy(chapters = withProblems(loaded.chapters + added)) + } + } + + fun rename(id: String, name: String) { + updateLoaded { loaded -> + loaded.copy( + chapters = withProblems( + loaded.chapters.map { if (it.id == id) it.copy(name = name) else it } + ) + ) + } + } + + fun remove(id: String) { + updateLoaded { loaded -> + loaded.copy(chapters = withProblems(loaded.chapters.filterNot { it.id == id })) + } + } + + private fun withProblems(chapters: List): List = + UploadReview.withProblems(chapters, existingByText) + + private inline fun updateLoaded( + crossinline block: (UploadChaptersUIState.Loaded) -> UploadChaptersUIState.Loaded, + ) { + _uiState.update { state -> + if (state is UploadChaptersUIState.Loaded) block(state) else state + } + } + + /** + * Puts every proposable chapter to the group, one session each, in order. + * + * **Sequentially, not concurrently.** Each proposal writes a session and + * publishes group events, so N at once is N interleaved MLS epochs -- and a + * group event that arrives a moment out of order is dropped for good; see + * `docs/mls-skipped-keys.md`. An upload is not in a hurry. + * + * Indices are allocated once here rather than re-read between sessions. The + * base is read fresh, because a chapter may have arrived since the files + * were picked, and then they are handed out in order -- re-asking per + * chapter would make the numbering depend on how fast the sessions are + * written. + */ + fun propose( + localChatRoom: LocalChatRoom, + artifactVersion: MantraArtifactVersion, + onDone: (proposed: Int, failed: Int) -> Unit, + ) { + val loaded = _uiState.value as? UploadChaptersUIState.Loaded ?: return + if (loaded.isProposing) return + + val queue = loaded.proposable.map { it.id } + if (queue.isEmpty()) return + + updateLoaded { it.copy(isProposing = true) } + + viewModelScope.launch(Dispatchers.IO) { + var index = nextIndex(artifactVersion) + var proposed = 0 + var failed = 0 + + for (id in queue) { + val row = (_uiState.value as? UploadChaptersUIState.Loaded) + ?.chapters + ?.firstOrNull { it.id == id } + ?: continue + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + lead = ChapterEvent.build( + artifactVersionId = artifactVersion.id, + name = row.name, + originalText = row.markdown, + index = index, + wordCount = row.wordCount, + characterCount = row.characterCount, + ), + // The chunks are cut from the chapter the session + // authored, because a chunk names its chapter's id and + // that id is a hash over the group's key at the room's + // path -- which this view model neither knows nor should. + dependents = ChunkEvent::splitOf, + ) + }.onFailure { error -> + logger.e("Could not propose ${row.fileName} as a chapter", error) + }.getOrNull() + + if (session != null) { + markProposed(id, session.id) + proposed++ + index++ + } else { + markFailed(id) + failed++ + } + } + + passes++ + updateLoaded { + it.copy( + isProposing = false, + lastOutcome = ProposeOutcome( + proposed = proposed, + failed = failed, + pass = passes, + ), + ) + } + + viewModelScope.launch(Dispatchers.Main) { onDone(proposed, failed) } + } + } + + private fun markProposed(id: String, sessionId: String) { + updateLoaded { loaded -> + loaded.copy( + chapters = loaded.chapters.map { + if (it.id == id) it.copy(sessionId = sessionId, problem = null) else it + } + ) + } + } + + private fun markFailed(id: String) { + updateLoaded { loaded -> + loaded.copy( + chapters = loaded.chapters.map { + if (it.id == id) it.copy(problem = UploadProblem.ProposalFailed) else it + } + ) + } + } + + private suspend fun nextIndex(artifactVersion: MantraArtifactVersion): Int = + ChapterIndexing.nextIndex( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + chatRoomId = chatRoomId, + artifactVersionId = artifactVersion.id, + ) + + companion object { + private const val TAG = "UploadChaptersViewModel" + + /** + * The most files one upload will take. + * + * Not a protocol limit -- the protocol has no opinion -- but a limit on + * how much of the room's attention one tap may spend. Twenty files is + * twenty proposals, each of which every other member is asked to read + * and approve. Somebody who means to upload a hundred chapters can do it + * in five passes and will know they did. + */ + const val MAX_FILES_PER_UPLOAD: Int = 20 + + fun factory( + activeUserPublicKey: HexKey, + artifactId: String, + chatRoomId: String, + relayHint: String?, + initialUIState: UploadChaptersUIState = UploadChaptersUIState.Loading, + mantraRepository: MantraRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + UploadChaptersViewModel( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifactId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialUIState = initialUIState, + mantraRepository = mantraRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt new file mode 100644 index 00000000..78070b7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadChaptersUIState.kt @@ -0,0 +1,165 @@ +package press.mantra.compose.ui.view.state + +import androidx.compose.runtime.Immutable +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface UploadChaptersUIState { + data object Loading : UploadChaptersUIState + + data class Error(val message: String) : UploadChaptersUIState + + data class Loaded( + val localChatRoom: LocalChatRoom, + val artifact: MantraArtifact, + /** Chapters hang off a version; without one there is nothing to upload into. */ + val artifactVersion: MantraArtifactVersion?, + val canSign: Boolean, + /** + * The files picked so far, in the order they will be proposed. + * + * Nothing here has been proposed until a row carries a session id. The + * screen is a review step, which is the one place this departs visibly + * from the web app it mirrors: there, twelve files become twelve + * chapters that belong to the uploader and can be fixed afterwards. Here + * they become twelve requests for the group's own signature, and + * anything wrong in one is wrong in something a quorum is about to put + * its name to. + */ + val chapters: List = emptyList(), + /** + * The index the first proposable chapter will take. + * + * Read when the files are picked so the list can show where each one + * lands, and read again when proposing -- a chapter may have arrived in + * between, and the signed number has to be the later answer. + */ + val baseIndex: Int = 0, + val isProposing: Boolean = false, + /** + * What the last pass did, so the screen can say so. + * + * In the state rather than handed to the callback, because the sentence + * is a formatted string resource and `stringResource` is composable + * while a callback is not. Read in composition, shown from an effect. + */ + val lastOutcome: ProposeOutcome? = null, + ) : UploadChaptersUIState { + + /** Rows that could be proposed as they stand. */ + val proposable: List + get() = chapters.filter { it.sessionId == null && it.problem?.blocking != true } + + val proposed: List get() = chapters.filter { it.sessionId != null } + + val canPropose: Boolean + get() = artifactVersion != null && canSign && !isProposing && proposable.isNotEmpty() + } +} + +/** The result of one pass of proposing. */ +@Immutable +data class ProposeOutcome( + val proposed: Int, + val failed: Int, + /** Distinguishes two passes with identical counts, so the second is still announced. */ + val pass: Int, +) + +/** + * One picked file, as the chapter it would become. + * + * [markdown] is already decoded, BOM-stripped and newline-normalised by + * `ChapterText.decode`, so it is the text that would be signed rather than + * whatever the file held. + */ +@Immutable +data class PickedChapter( + /** Stable across edits and removals, so the list keeps its identity. */ + val id: String, + val fileName: String, + /** + * Editable, defaulting to `ChapterNaming.forFile`. + * + * A filename is right often enough to be the default and wrong often enough + * -- `chapter-01.md`, `ch1_final_v2.md` -- that it has to be fixable before a + * quorum sees it. The name is signed into the chapter along with the text. + */ + val name: String, + val markdown: String, + val chunks: List, + val wordCount: Int, + val characterCount: Int, + val proposalBytes: Int, + val problem: UploadProblem? = null, + /** Set once this chapter has been put to the group. Never unset. */ + val sessionId: String? = null, +) + +/** + * What is wrong with one row. + * + * Per row rather than per upload, because a bad file should not cost the other + * eleven. [blocking] is the difference between "this cannot be proposed" and + * "this is probably not what you meant". + */ +@Immutable +sealed interface UploadProblem { + val blocking: Boolean + + /** Not decodable as UTF-8, or carrying a NUL. */ + data object NotText : UploadProblem { + override val blocking: Boolean get() = true + } + + /** Over `ChapterText.MAX_FILE_BYTES`. */ + data object TooLarge : UploadProblem { + override val blocking: Boolean get() = true + } + + /** Nothing left after trimming, which is a chapter with nothing to translate. */ + data object Empty : UploadProblem { + override val blocking: Boolean get() = true + } + + /** More paragraphs than one session signs. */ + data class TooManyChunks(val count: Int) : UploadProblem { + override val blocking: Boolean get() = true + } + + /** Within the chunk cap but over what a proposal carries. */ + data class TooLargeToPropose(val bytes: Int) : UploadProblem { + override val blocking: Boolean get() = true + } + + /** No name, and nothing in the file to derive one from. */ + data object Unnamed : UploadProblem { + override val blocking: Boolean get() = true + } + + /** + * The same text as a chapter this version already has, or as another row. + * + * **A warning, not a block.** A book can repeat a page. But the common cause + * is picking the same directory twice, and two chapters with the same text + * are two different event ids that no later de-duplication can merge -- so + * it is worth saying before a quorum signs both. + */ + data class Duplicate(val of: String) : UploadProblem { + override val blocking: Boolean get() = false + } + + /** + * Proposing this one failed. + * + * **Not blocking**, because a retry is exactly what should happen: a failed + * proposal is retried by making a new one, never by re-opening the session + * that failed, and `proposeSigningBatch` mints fresh nonce seeds by + * construction. The row keeps its reason so "propose the remaining" has + * something to point at. + */ + data object ProposalFailed : UploadProblem { + override val blocking: Boolean get() = false + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt new file mode 100644 index 00000000..cf817af2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt @@ -0,0 +1,121 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.files.PickedFile +import press.mantra.compose.managers.FrostSigningManager +import press.mantra.compose.nostr.nip30303.ChapterProposal +import press.mantra.compose.text.ChapterNaming +import press.mantra.compose.text.ChapterText +import press.mantra.compose.text.Markdown + +/** + * Turning picked files into reviewable rows, and deciding what is wrong with + * each. + * + * Pure, and separate from `UploadChaptersViewModel` on purpose. Everything here + * is a function of the files, the names typed over them and the chapters the + * version already has -- no repository, no coroutine, no database -- which is + * most of what the upload screen actually does and all of what is worth testing + * closely. What the view model keeps is the orchestration: reading the version, + * allocating indices and opening a session per chapter. + */ +object UploadReview { + + /** The most paragraphs a chapter can be proposed with; the chapter takes a place too. */ + const val MAX_CHUNKS_PER_CHAPTER: Int = FrostSigningManager.MAX_BATCH_SIZE - 1 + + /** + * Rows for [files], numbered from [startOrdinal] so they can be appended to + * rows already listed. + * + * Problems are not decided here -- see [withProblems] -- beyond the two that + * are properties of the bytes rather than of the list. + */ + fun rows(files: List, startOrdinal: Int): List = + files.mapIndexed { offset, file -> row(file, startOrdinal + offset) } + + private fun row(file: PickedFile, ordinal: Int): PickedChapter { + val decoded = ChapterText.decode(file.bytes) + val markdown = (decoded as? ChapterText.Result.Text)?.markdown.orEmpty() + val chunks = Markdown.splitParagraphs(markdown) + + return PickedChapter( + // Neither the file's name nor its text is unique -- two directories + // can each hold a `01.md`, and a book can repeat a page -- and the + // ordinal is, within the one upload this id has to be unique in. + id = "$ordinal:${file.name}", + fileName = file.name, + name = if (decoded is ChapterText.Result.Text) { + ChapterNaming.forFile(file.name, markdown) + } else { + "" + }, + markdown = markdown, + chunks = chunks, + wordCount = Markdown.wordCount(markdown), + characterCount = Markdown.characterCount(markdown), + proposalBytes = ChapterProposal.proposalBytes(markdown, chunks), + problem = when (decoded) { + ChapterText.Result.NotText -> UploadProblem.NotText + ChapterText.Result.TooLarge -> UploadProblem.TooLarge + is ChapterText.Result.Text -> null + }, + ) + } + + /** + * Re-decides every row's problem. + * + * Whole-list rather than per row, because two of the answers are about the + * list: a duplicate is a relation between rows, and removing one row can + * clear the warning on another. + * + * A row already proposed is returned untouched. Its chapter is in front of + * the group, and saying something new about it would be a comment on a + * decision already taken. + * + * [existingByText] maps the text of each chapter the version already has to + * its name, so a file that is already in the version can be recognised as + * one -- by its text, since that is what two chapters would have in common. + */ + fun withProblems( + chapters: List, + existingByText: Map, + ): List { + val seen = mutableMapOf() + + return chapters.map { chapter -> + if (chapter.sessionId != null) return@map chapter + + // Nothing about the list can fix a file that is not text, so those + // two survive whatever else is decided below. + if (chapter.problem == UploadProblem.NotText || + chapter.problem == UploadProblem.TooLarge + ) { + return@map chapter + } + + val duplicateOf = existingByText[chapter.markdown] ?: seen[chapter.markdown] + if (!seen.containsKey(chapter.markdown)) { + seen[chapter.markdown] = chapter.name.ifBlank { chapter.fileName } + } + + chapter.copy( + problem = when { + chapter.chunks.isEmpty() -> UploadProblem.Empty + chapter.name.isBlank() -> UploadProblem.Unnamed + chapter.chunks.size > MAX_CHUNKS_PER_CHAPTER -> + UploadProblem.TooManyChunks(chapter.chunks.size) + !ChapterProposal.fits(chapter.markdown, chapter.chunks) -> + UploadProblem.TooLargeToPropose(chapter.proposalBytes) + duplicateOf != null -> UploadProblem.Duplicate(duplicateOf) + // A row that failed to propose keeps saying so until it is + // retried, which is what makes the button honest about there + // being something left to do. + chapter.problem == UploadProblem.ProposalFailed -> + UploadProblem.ProposalFailed + else -> null + } + ) + } + } +} 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 new file mode 100644 index 00000000..479ed1f3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadReviewTest.kt @@ -0,0 +1,180 @@ +package press.mantra.compose.ui.view.state + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import press.mantra.compose.files.PickedFile + +/** + * What a picked file is offered as, and what the screen refuses to offer. + * + * The point of the review step is that nothing here reaches a quorum without + * somebody having looked at it, so these assert the two halves of that: the + * defaults are good enough that looking is quick, and everything that would be + * wrong in a signed chapter is said before it is signed. + */ +class UploadReviewTest { + + private fun file(name: String, text: String) = + PickedFile(name = name, bytes = text.encodeToByteArray()) + + private fun reviewed( + vararg files: PickedFile, + existing: Map = emptyMap(), + ): List = + UploadReview.withProblems(UploadReview.rows(files.toList(), startOrdinal = 0), existing) + + private fun paragraphs(count: Int) = + (1..count).joinToString("\n\n") { "Paragraph $it." } + + @Test + fun `an ordinary markdown file is offered with no problem`() { + val rows = reviewed(file("03 - The Wreck.md", "# The wreck\n\nIt began badly.\n")) + + assertEquals(1, rows.size) + assertNull(rows.single().problem) + assertEquals("The Wreck", rows.single().name) + assertEquals(listOf("# The wreck", "It began badly."), rows.single().chunks) + } + + @Test + fun `the name falls through to the text when the filename is only an ordinal`() { + val rows = reviewed(file("01.md", "# Chapter one\n\nIt began badly.\n")) + + assertEquals("Chapter one", rows.single().name) + } + + @Test + 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")) + + assertEquals(listOf("One two.", "Three."), rows.single().chunks) + assertEquals(3, rows.single().wordCount) + } + + @Test + 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)), + file("also-good.md", "Also fine.\n"), + ) + + assertEquals(UploadProblem.NotText, rows[1].problem) + assertNull(rows[0].problem) + assertNull(rows[2].problem) + } + + @Test + fun `an empty file has nothing to translate`() { + assertEquals(UploadProblem.Empty, reviewed(file("blank.md", " \n\n ")).single().problem) + } + + @Test + fun `a name cleared by hand blocks the row until it is typed`() { + val rows = reviewed(file("The Wreck.md", "It began badly.\n")) + val cleared = UploadReview.withProblems( + rows.map { it.copy(name = " ") }, + emptyMap(), + ) + + assertEquals(UploadProblem.Unnamed, cleared.single().problem) + + val named = UploadReview.withProblems( + cleared.map { it.copy(name = "The Wreck") }, + emptyMap(), + ) + assertNull(named.single().problem) + } + + @Test + fun `too many paragraphs for one session is said per file`() { + val rows = reviewed( + file("long.md", paragraphs(UploadReview.MAX_CHUNKS_PER_CHAPTER + 1)), + file("fine.md", paragraphs(UploadReview.MAX_CHUNKS_PER_CHAPTER)), + ) + + assertEquals( + UploadProblem.TooManyChunks(UploadReview.MAX_CHUNKS_PER_CHAPTER + 1), + rows[0].problem, + ) + assertNull(rows[1].problem) + } + + @Test + fun `a chapter within the chunk cap but over what a proposal carries is refused`() { + // Forty paragraphs is well inside the chunk cap, and 28 KiB of them is + // not inside what one group message carries -- the two caps are + // different limits and this is the file that proves it. + val long = (1..40).joinToString("\n\n") { "w ".repeat(360).trim() } + + val problem = reviewed(file("fat.md", long)).single().problem + + assertTrue(problem is UploadProblem.TooLargeToPropose, "got $problem") + } + + @Test + fun `the same file picked twice warns rather than blocks`() { + val rows = reviewed( + file("the-wreck.md", "It began badly.\n"), + file("copy-of-the-wreck.md", "It began badly.\n"), + ) + + assertNull(rows[0].problem) + assertEquals(UploadProblem.Duplicate("the-wreck"), rows[1].problem) + // Not blocking, because a book can repeat a page -- but two chapters + // with the same text are two event ids that nothing later can merge. + assertEquals(false, rows[1].problem?.blocking) + } + + @Test + fun `a file already in the version is recognised by its text`() { + val rows = reviewed( + file("ch1.md", "It began badly.\n"), + existing = mapOf("It began badly.\n" to "The Wreck"), + ) + + assertEquals(UploadProblem.Duplicate("The Wreck"), rows.single().problem) + } + + @Test + fun `removing one row clears the warning on the other`() { + val rows = reviewed( + file("a.md", "Same words.\n"), + file("b.md", "Same words.\n"), + ) + assertEquals(UploadProblem.Duplicate("a"), rows[1].problem) + + val remaining = UploadReview.withProblems(rows.drop(1), emptyMap()) + + // The reason a whole-list pass exists rather than a per-row check. + assertNull(remaining.single().problem) + } + + @Test + fun `a proposed row is left exactly as it is`() { + val proposed = reviewed(file("a.md", "Same words.\n")) + .map { it.copy(sessionId = "session", name = " ") } + + // Blank name, duplicate text, and still untouched: its chapter is in + // front of the group and nothing here is a comment on that. + val after = UploadReview.withProblems( + proposed, + existingByText = mapOf("Same words.\n" to "The Wreck"), + ) + + assertEquals(proposed, after) + } + + @Test + fun `rows appended later keep distinct ids`() { + val first = UploadReview.rows(listOf(file("01.md", "One.")), startOrdinal = 0) + val second = UploadReview.rows(listOf(file("01.md", "Two.")), startOrdinal = first.size) + + // Two directories can each hold a `01.md`, and a list keyed by name + // would drop the second or animate the two into each other. + assertEquals(2, (first + second).map { it.id }.toSet().size) + } +} From 9430396e5afd9ac72b93e528d5408d5db5535cde Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:16:21 +0200 Subject: [PATCH 10/32] feat(projects): say what a project is to the member being asked to sign it Phase 4 of docs/project-feature.md. Three branches in `ProposedEvent.summarize`, or members would be asked to sign "Event of kind 30330". The project's branch shows the creator, and that is not decoration. The creator tag is the group attesting that X started this; the creator signs nothing, the room does, so a member could propose a project crediting somebody else and a quorum that does not read would sign it. Nothing checks this and nothing can -- the only mitigation is that the claim is in front of the person agreeing to it. `summarize` gains two optional parameters. `chatRoomId` is read by one branch and only to leave something out: a project owned by the room it is in is the ordinary case, and saying "owned by this group" every time is how a member learns to stop reading the line that matters when somebody else is on it. All four call sites already had the room, so this is threaded rather than plumbed -- including through `WhatIsBeingSigned`, which took the events and not the state. `resolve` is the seam for turning keys and ids into names, synchronous because two of the four call sites are composables and cannot await a database. It defaults to resolving nothing, which is honest rather than finished: a membership event carries two 64-character ids, so without it the summary is a truncated hash. That is tolerable in the batch that founds a project -- the project's own line says what is happening and the memberships read as its continuation, and the project id is unresolvable there anyway since nothing has applied it yet. It is not tolerable for a membership proposed on its own, which is Phase 7's second screen, and that is where the resolver gets filled in. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/text/ProposedEvent.kt | 93 ++++++++++++- .../BroadcastGroupSignedEventScreen.kt | 5 +- .../ui/composable/FrostSigningScreen.kt | 15 +- .../ui/view/model/ChatMessageListViewModel.kt | 7 +- .../ui/view/model/ProposalListViewModel.kt | 2 +- .../mantra/compose/text/ProposedEventTest.kt | 128 ++++++++++++++++++ 6 files changed, 241 insertions(+), 9 deletions(-) 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 7b11f466..0513166b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -1,6 +1,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.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -8,6 +9,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -31,7 +35,34 @@ object ProposedEvent { */ data class Summary(val label: String, val detail: String) - fun summarize(event: Event): Summary = when (event.kind) { + /** + * [chatRoomId] is the room the proposal is in, where one is known. + * + * Only one branch reads it, and only to leave something out: a project owned + * by the room it is in is the ordinary case, and saying "owned by this group" + * on every project trains a member not to read the line that matters when + * somebody else is on it. Null simply says everything. + * + * [resolve] turns a public key or an event id into something a person can + * read, or null when it cannot. It is synchronous on purpose -- this is + * called from composables as well as view models -- so a caller with a + * database behind it resolves what it needs first and passes a lookup into + * the map. + * + * The default resolves nothing, which is honest rather than ideal. A + * membership event carries two 64-character ids and nothing else, so without + * a resolver it can only be described by a truncated hash. That is tolerable + * inside the batch that founds a project, where the project's own line names + * what is happening and the memberships read as its continuation -- and in + * that batch the project id is unresolvable anyway, since nothing has + * applied it yet. It is not tolerable for a membership proposed on its own, + * which is the whole of what a member is being asked to agree to. + */ + fun summarize( + event: Event, + chatRoomId: String? = null, + resolve: (HexKey) -> String? = { null }, + ): Summary = when (event.kind) { DialectEvent.KIND -> Summary( label = "New dialect", detail = DialectEvent( @@ -125,6 +156,56 @@ object ProposedEvent { } ) + ProjectEvent.KIND -> Summary( + label = "New project", + detail = ProjectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { project -> + listOfNotNull( + project.name(), + event.content.takeIf { it.isNotBlank() }, + // Shown because signing this is what makes it the group's + // statement that X started it. A member who cannot see the + // claim cannot be said to have agreed to it -- and nothing + // else checks it, since the creator signs nothing here. + project.creator()?.let { "started by ${describe(it, resolve)}" }, + // Only the owners who are not this room. See [chatRoomId]. + project.owners() + .filter { it != chatRoomId } + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") { describe(it, resolve) } + ?.let { "owned by $it" }, + ).joinToString(" · ") + } + ) + + ProjectArtifactVersionEvent.KIND -> Summary( + label = "A source version joins the project", + detail = ProjectArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { membership -> + // The version first: in a founding batch the project is being + // signed two lines up and its id resolves to nothing, because + // nothing has applied it yet. + listOfNotNull( + membership.artifactVersionId()?.let { describe(it, resolve) }, + membership.projectId()?.let { resolve(it) }?.let { "to $it" }, + ).joinToString(" · ").ifBlank { "Unknown version" } + } + ) + + ProjectTranslationArtifactVersionEvent.KIND -> Summary( + label = "A translation joins the project", + detail = ProjectTranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { membership -> + listOfNotNull( + membership.translationArtifactVersionId()?.let { describe(it, resolve) }, + membership.projectId()?.let { resolve(it) }?.let { "to $it" }, + ).joinToString(" · ").ifBlank { "Unknown translation" } + } + ) + // The one thing a group signs that is about the group rather than about // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: @@ -157,4 +238,14 @@ object ProposedEvent { else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } + + /** + * [id] as a name if [resolve] has one, and as a short hash otherwise. + * + * Twelve characters because that is what the rest of the signing screens + * truncate to. A short hash is a poor thing to put in front of somebody + * deciding whether to sign, and saying so is the point: see [summarize]. + */ + private fun describe(id: HexKey, resolve: (HexKey) -> String?): String = + resolve(id) ?: id.take(12) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 99fe9a19..9c7e9318 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -188,7 +188,10 @@ fun BroadcastGroupSignedEventScreen( // what goes on the wire, because the point of copying it is to // hand somebody something they can verify. val json = signedEvent.toEvent().toJson() - val summary = ProposedEvent.summarize(signedEvent.toEvent()) + val summary = ProposedEvent.summarize( + event = signedEvent.toEvent(), + chatRoomId = signedEvent.chatRoomId, + ) val canSend = !isPending diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index fbecea82..9ab44345 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -165,7 +165,11 @@ fun FrostSigningScreen( .padding(MaterialTheme.spacing.space250), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200) ) { - WhatIsBeingSigned(proposed, state.items.size) + WhatIsBeingSigned( + events = proposed, + expected = state.items.size, + chatRoomId = state.localChatRoom.chatRoom.id, + ) HorizontalDivider() @@ -335,7 +339,7 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * sign something they cannot check. */ @Composable -private fun WhatIsBeingSigned(events: List, expected: Int) { +private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: String) { if (expected == 0 || events.size != expected) { Text( text = if (expected <= 1) { @@ -361,7 +365,7 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { events.forEachIndexed { index, event -> if (index > 0) HorizontalDivider() - OneThingBeingSigned(event) + OneThingBeingSigned(event, chatRoomId) } Text( @@ -376,8 +380,9 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { /** One event of the batch, described as the thing it is. */ @Composable -private fun OneThingBeingSigned(event: Event) { - val summary = ProposedEvent.summarize(event) +private fun OneThingBeingSigned(event: Event, chatRoomId: String) { + // The room, so a project owned by this group does not say so on every line. + val summary = ProposedEvent.summarize(event, chatRoomId = chatRoomId) Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) { Text(text = summary.label, style = MaterialTheme.typography.labelMedium) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 7ef1bdb0..78fd1923 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -228,7 +228,12 @@ class ChatMessageListViewModel( // something, and the notice should say what. lead = local.items.firstNotNullOfOrNull { item -> Event.fromJsonOrNull(item.unsignedEventJson) - ?.let { ProposedEvent.summarize(it) } + ?.let { + ProposedEvent.summarize( + event = it, + chatRoomId = localChatRoom.chatRoom.id, + ) + } }, eventCount = local.items.size ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt index 938d3d83..42e82e44 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt @@ -86,7 +86,7 @@ class ProposalListViewModel( else -> local.proposer?.humanReadableNameOrPubkey() ?: local.session.coordinatorPublicKey.shortened() }, - signs = events.map { ProposedEvent.summarize(it) }, + signs = events.map { ProposedEvent.summarize(it, chatRoomId = chatRoomId) }, unreadable = items.size - events.size, awaitsYou = FrostSigningManager.isAwaitingApproval(local.session, items) ) 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 9ecddf7e..5992bab2 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -1,8 +1,12 @@ 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.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * How an event a group signs is said to the member signing it. @@ -23,6 +27,130 @@ class ProposedEventTest { assertEquals("An article", summary.detail) } + // --- projects --- + // + // A project says who started it and who owns it, and both are claims the + // group is being asked to put its signature to rather than facts anybody + // checks. The creator signs nothing; the owners sign nothing. So the only + // thing standing between "the group attested that X started this" and "the + // proposer typed X" is whether the member signing could see the claim. That + // is what these pin. + + private val roomId = "r".repeat(64) + private val creator = "c".repeat(64) + private val outsider = "d".repeat(64) + + private fun project(owners: Set) = ProjectEvent.build( + name = "Gospel of John", + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = owners, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + @Test + fun `a project names itself and whoever started it`() { + val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId) + + assertEquals("New project", summary.label) + assertTrue(summary.detail.contains("Gospel of John")) + assertTrue(summary.detail.contains("The four chapters.")) + assertTrue(summary.detail.contains("started by ${creator.take(12)}")) + } + + @Test + fun `a project owned by the room it is in says nothing about owners`() { + // The ordinary case. Saying "owned by this group" every time is how a + // member learns to stop reading the line. + val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId) + + assertTrue(!summary.detail.contains("owned by"), summary.detail) + } + + @Test + fun `a project owned by somebody else says who, and not the room`() { + val summary = ProposedEvent.summarize( + project(setOf(roomId, outsider)), + chatRoomId = roomId + ) + + assertTrue(summary.detail.contains("owned by"), summary.detail) + assertTrue(summary.detail.contains(outsider.take(12)), summary.detail) + assertTrue(!summary.detail.contains(roomId.take(12)), summary.detail) + } + + @Test + 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) + assertTrue(summary.detail.contains(outsider.take(12)), summary.detail) + } + + @Test + fun `a resolver turns the keys into names`() { + val names = mapOf(creator to "Kgothatso", outsider to "Exonumia") + + val summary = ProposedEvent.summarize( + project(setOf(roomId, outsider)), + chatRoomId = roomId, + resolve = names::get + ) + + assertTrue(summary.detail.contains("started by Kgothatso"), summary.detail) + assertTrue(summary.detail.contains("owned by Exonumia"), summary.detail) + } + + @Test + fun `a membership is a hash without a resolver and a name with one`() { + val versionId = "f".repeat(64) + val projectId = "e".repeat(64) + + val source = ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = versionId, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + // What a member sees in a founding batch, where the project is being + // signed on the line above and resolves to nothing because nothing has + // applied it yet. + val bare = ProposedEvent.summarize(source, chatRoomId = roomId) + assertEquals("A source version joins the project", bare.label) + assertEquals(versionId.take(12), bare.detail) + + // And what it has to look like before a membership is proposed on its + // own, which is the whole of what is being agreed to there. + val named = ProposedEvent.summarize( + source, + chatRoomId = roomId, + resolve = mapOf(versionId to "To Kill a Mockingbird 1.0", projectId to "Gospel of John")::get + ) + assertEquals("To Kill a Mockingbird 1.0 · to Gospel of John", named.detail) + } + + @Test + fun `a translation joining a project is named as a translation`() { + val translationId = "f".repeat(64) + + val membership = ProjectTranslationArtifactVersionEvent.build( + projectId = "e".repeat(64), + translationArtifactVersionId = translationId, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + val summary = ProposedEvent.summarize(membership, chatRoomId = roomId) + + assertEquals("A translation joins the project", summary.label) + assertEquals(translationId.take(12), summary.detail) + } + private fun event( kind: Int, tags: Array> = emptyArray(), From 5fac2fc7b726d34cd6510d4ac906fa05c70bc0ff Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:27:02 +0200 Subject: [PATCH 11/32] feat(projects): a late member is told what the room's work is for Phase 5 of docs/project-feature.md. Three kinds appended to `ChronicleEvent.APPLY_ORDER`, which is all the mechanism needs: `CHRONICLABLE_KINDS`, `applyRank`, `inApplyOrder` and `isChroniclable` all derive from that one list, and `signedEventsOf` already filters on it. Appending is not arbitrary. A membership is a foreign key into both a project and a version, so it has to apply after everything it can name, and putting the three on the end gets that right by construction -- everything a project groups is already above it. A page in the wrong order is a refused insert on the one device with nothing to fall back on. Then four existing tests failed, and each was telling the truth. Three of them compare what a seeded room chronicles against CHRONICLABLE_KINDS itself, so a kind added to the set and not to the seed fails them. That is the point of writing them that way, so the project went into the canonical seed in both chronicle suites rather than into a helper beside them. The fourth is the one worth reading. `every chroniclable kind has a round-trip test above` asserts that every kind a chronicle carries can also be rebuilt from its row -- and projects deliberately cannot, because the rebuild exists for rooms older than schema v13 and projects arrive at v21. So the meta-test now excludes them explicitly, and the gap is pinned where it actually bites: `a project is only ever chronicled off the record` holds a project whose `GroupSignedEvent` is missing and shows the row is there and the chronicle is not. That is reachable -- `recordSignedEvents` catches its own failure so a ceremony that succeeded is not reported as failed -- and it is worth knowing is possible rather than worth preventing. `the rebuild alone reaches everything but the projects` pins the other side, so anyone adding a `toProjectEvent` fails a test that tells them where the comment explaining why is. Also corrected a stale count in the allowlist's own comment: it said six of thirteen nip30303 kinds reach a signing session while the list below it had eight. Eleven of sixteen now, with `SubmissionEvent` named among the exclusions since it is the envelope rather than the work. 1403 tests, no failures. Co-Authored-By: Claude Opus 5 --- .../compose/nostr/chronicle/ChronicleEvent.kt | 27 ++- .../nostr/chronicle/ChronicleEventTest.kt | 52 ++++- .../nostr/chronicle/ChronicleRoundTripTest.kt | 35 +++- .../compose/managers/ChronicleApplyJvmTest.kt | 41 ++++ .../managers/ChronicleAssemblyJvmTest.kt | 177 +++++++++++++++++- 5 files changed, 327 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt index c6a96f5d..0e6bf12a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEvent.kt @@ -19,6 +19,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -167,8 +170,8 @@ class ChronicleEvent( * ### What is missing from it, and why * * **Only kinds the group actually signs can be here**, because a chronicle - * that cannot be verified is one that has to be believed. Six of the - * thirteen nip30303 kinds reach a signing session; the rest travel as + * that cannot be verified is one that has to be believed. Eleven of the + * sixteen nip30303 kinds reach a signing session; the rest travel as * rumors -- empty signature, member author, vouched for by the MLS frame * they arrived in and by nothing that survives leaving it. * @@ -179,6 +182,8 @@ class ChronicleEvent( * - The contributor lists (30305, 30307, 30310). `applyInnerEvent` has * no arm that writes a row for any of them, so chronicling them would * cost bytes and restore nothing. + * - `SubmissionEvent` (30312), which is the envelope rather than the + * work: a chronicle carries payloads. * * Two kinds were on that list and are not any more, because the app * stopped leaving them unsigned. `ArtifactVersionEvent` (30301) used to be @@ -188,6 +193,10 @@ class ChronicleEvent( * and is now proposed to the group like everything else. Both are * therefore checkable, and a chronicle that left them out would hand a new * member the whole structure and none of the prose. + * + * The three project kinds (30330-30332) were signed from the day they + * existed, so they have no such story: they are here because a group's + * work includes what the work is for. */ private val APPLY_ORDER: List = listOf( DialectEvent.KIND, @@ -198,6 +207,20 @@ class ChronicleEvent( TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, + // Projects are last, and that is the whole of what this list says + // about them. A membership is a foreign key into both a project and + // a version, so it has to apply after everything it can name -- and + // appending gets that right by construction, since everything a + // project groups is already above it. + // + // Chroniclable where the contributor lists are not, for the opposite + // of their reason: these have an arm that writes a row, and they are + // signed rather than submitted, so a receiver can check them. A late + // member handed the whole library and no account of what any of it + // is for has been handed the smaller half. + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, ) /** Every kind a chronicle may carry. */ 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 ee16573b..0bb7fa12 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 @@ -19,6 +19,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.SubmissionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent @@ -190,9 +193,56 @@ class ChronicleEventTest { TranslationArtifactVersionEvent.KIND, TranslationChapterEvent.KIND, TranslationChunkEvent.KIND, + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, ).forEach { assertTrue(ChronicleEvent.isChroniclable(it), "kind $it should be chroniclable") } - assertEquals(8, ChronicleEvent.CHRONICLABLE_KINDS.size) + assertEquals(11, ChronicleEvent.CHRONICLABLE_KINDS.size) + } + + @Test + fun `a membership applies after the project and the version it names`() { + // The only thing APPLY_ORDER has to get right about projects. A + // membership is a foreign key into both, so a page that applied one + // first would have the insert refused and the membership lost -- on the + // one device that most needs it, since a chronicle is what a member who + // joined late is given instead of having been there. + val project = ChronicleEvent.applyRank(ProjectEvent.KIND)!! + val source = ChronicleEvent.applyRank(ProjectArtifactVersionEvent.KIND)!! + val translation = ChronicleEvent.applyRank(ProjectTranslationArtifactVersionEvent.KIND)!! + + assertTrue(source > project, "a source membership must apply after its project") + assertTrue(translation > project, "a translation membership must apply after its project") + + assertTrue( + source > ChronicleEvent.applyRank(ArtifactVersionEvent.KIND)!!, + "a source membership must apply after the version it names" + ) + assertTrue( + translation > ChronicleEvent.applyRank(TranslationArtifactVersionEvent.KIND)!!, + "a translation membership must apply after the translation it names" + ) + } + + @Test + fun `a page of a whole project sorts into an order that will apply`() { + val shuffled = listOf( + payload(ProjectArtifactVersionEvent.KIND), + payload(ProjectEvent.KIND), + payload(ArtifactVersionEvent.KIND), + payload(ArtifactEvent.KIND), + ) + + assertEquals( + listOf( + ArtifactEvent.KIND, + ArtifactVersionEvent.KIND, + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ), + ChronicleEvent.inApplyOrder(shuffled).map { it.kind } + ) } @Test diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt index 7d53214a..e626d223 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleRoundTripTest.kt @@ -34,6 +34,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -408,15 +411,45 @@ class ChronicleRoundTripTest { } } + /** + * The kinds that reach a chronicle only off the record, never by rebuild. + * + * Everything else here has two sources, because a room whose work predates + * `GroupSignedEvent` at schema v13 has rows and no events, and the rebuild is + * what makes such a room whole. Projects arrive at v21, so no project row + * anywhere is in that position and a `toProjectEvent` would be new code for + * a mechanism with a removal checklist already written against it -- see + * `MantraProject` and docs/member-chronicle.md. + * + * The cost is real and is asserted rather than assumed: + * `ChronicleAssemblyJvmTest.a project is only ever chronicled off the record` + * is what happens when the record is missing. + */ + private val notRebuildable = setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ) + @Test fun `every chroniclable kind has a round-trip test above`() { // The list and this file have to move together. A kind added to the // allowlist without a case here is one nobody has checked can be rebuilt, // and the failure is silent: the receiver drops it as a forgery. + // + // Unless it is a kind with no rebuild at all, which is a decision rather + // than an omission -- see [notRebuildable]. Those are excluded here and + // pinned in `ChronicleAssemblyJvmTest` instead, so adding one is still a + // thing somebody has to write down. assertEquals( 8, - ChronicleEvent.CHRONICLABLE_KINDS.size, + (ChronicleEvent.CHRONICLABLE_KINDS - notRebuildable).size, "a chroniclable kind was added or removed; add or remove its round-trip case" ) + + assertTrue( + notRebuildable.all { it in ChronicleEvent.CHRONICLABLE_KINDS }, + "a kind cannot be exempted from the rebuild unless it is chroniclable at all" + ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt index 6500d8c9..cd0eb5de 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt @@ -27,6 +27,7 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MarmotInnerEvent import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NostrEvent @@ -44,6 +45,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -307,6 +311,43 @@ class ChronicleApplyJvmTest { ) ) ) + + // What the work is for. Unlike everything above it, a project has to be + // *recorded* as well as applied to reach a chronicle: there is no + // `toProjectEvent`, so the deprecated rebuild cannot produce one, and + // the record is its only source. That is deliberate -- the rebuild + // serves rooms older than schema v13 and projects arrive at v21 -- and + // it is why `record` appears here and nowhere else in this seed. + val project = signed( + ProjectEvent.build( + name = "In Detention into isiZulu", + description = "The first chapter, with its back-translation.", + creatorPublicKey = oldMember, + ownerPublicKeys = setOf(chatRoomId), + createdAt = 1_700_000_070L, + ) + ).also { apply(sender, it) } + + val sourceMembership = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(version.id)).single() + ).also { apply(sender, it) } + + val translationMembership = signed( + ProjectTranslationArtifactVersionEvent + .membershipsOf(project, listOf(translationVersion.id)) + .single() + ).also { apply(sender, it) } + + sender.groupSignedEventDao().recordAll( + listOf(project, sourceMembership, translationMembership).map { + GroupSignedEvent.fromEvent( + event = it, + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(), + frostSigningSessionId = "session-1", + ) + } + ) } /** diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt index fe713e8d..a1c35dd1 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt @@ -37,6 +37,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -297,8 +300,40 @@ class ChronicleAssemblyJvmTest { createdAt = 1_700_000_060L ) ) + + // What the work is for. Seeded here rather than in the tests that are + // about projects, because `a chronicle holds every kind the group + // signed` compares what this room produces against CHRONICLABLE_KINDS + // itself -- so a kind added to that set and not to this seed fails three + // tests at once, which is the point of writing it that way. + // + // Proposed the way the form will propose it: the project leading, its + // memberships taking the id and the timestamp it comes back with. + seededProject = apply( + ProjectEvent.build( + name = "In Detention into isiZulu", + description = "The first chapter, with its back-translation.", + creatorPublicKey = newMember, + ownerPublicKeys = setOf(chatRoomId), + createdAt = 1_700_000_070L, + ) + ) + + apply( + ProjectArtifactVersionEvent + .membershipsOf(seededProject!!, listOf(version.id)) + .single() + ) + apply( + ProjectTranslationArtifactVersionEvent + .membershipsOf(seededProject!!, listOf(translationVersion.id)) + .single() + ) } + /** The project [seedSignedWork] signed, for the tests that are about it. */ + private var seededProject: Event? = null + private suspend fun chronicledPayloads(): List = ChronicleManager.assemble(db, chatRoomId, newMember) .flatMap { assertNotNull(ChronicleEvent.decodePage(it.content)) } @@ -321,6 +356,12 @@ class ChronicleAssemblyJvmTest { @Test fun `a chronicle holds every kind the group signed, and each of them once`() = runBlocking { seedSignedWork() + // On file, which is what a room signing work today looks like. Without + // this the chronicle is assembled entirely by the deprecated rebuild, + // which reaches eight of the eleven kinds -- it has no `toProjectEvent` + // and deliberately never will. That path is still covered, by + // `work held both ways travels exactly once` and the two below it. + recordEverythingApplied() val byKind = chronicledPayloads().groupBy { it.kind } @@ -332,10 +373,33 @@ class ChronicleAssemblyJvmTest { assertEquals(1, byKind[TranslationArtifactVersionEvent.KIND]?.size) assertEquals(1, byKind[TranslationChapterEvent.KIND]?.size) assertEquals(1, byKind[TranslationChunkEvent.KIND]?.size) + assertEquals(1, byKind[ProjectEvent.KIND]?.size) + assertEquals(1, byKind[ProjectArtifactVersionEvent.KIND]?.size) + assertEquals(1, byKind[ProjectTranslationArtifactVersionEvent.KIND]?.size) assertEquals(ChronicleEvent.CHRONICLABLE_KINDS, byKind.keys) } + @Test + fun `the rebuild alone reaches everything but the projects`() = runBlocking { + // The same room with nothing on file, which is the deprecated path in + // isolation. Pinned rather than left implicit: if somebody adds a + // `toProjectEvent` this fails, and the comment on `MantraProject` says + // why they should not. + seedSignedWork() + + val kinds = chronicledPayloads().map { it.kind }.toSet() + + assertEquals( + ChronicleEvent.CHRONICLABLE_KINDS - setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ), + kinds + ) + } + @Test fun `a chronicle is in dependency order across its whole length`() = runBlocking { seedSignedWork() @@ -451,7 +515,24 @@ class ChronicleAssemblyJvmTest { // Half the batch on file, the other half only as rows -- which is what // a room that was upgraded partway through its life looks like. - record(*applied.take(applied.size / 2).toTypedArray()) + // + // The projects are on file whichever half they fall in, because the + // rebuild cannot reach them: there is no `toProjectEvent`, deliberately. + // That is not a gap in this scenario, it is the scenario being + // impossible for them -- the rebuild serves rooms whose work predates + // `GroupSignedEvent` at v13, and projects arrive at v21. See + // `a project is only ever chronicled off the record` for the edge that + // does follow from it. + val projectKinds = setOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ) + val rebuildable = applied.filterNot { it.kind in projectKinds } + record( + *(rebuildable.take(rebuildable.size / 2) + + applied.filter { it.kind in projectKinds }).toTypedArray() + ) val payloads = chronicledPayloads() @@ -749,4 +830,98 @@ class ChronicleAssemblyJvmTest { chronicledTranslations().map { it.id }.toSet() ) } + + // ---- Projects --------------------------------------------------------- + + @Test + fun `a room's projects travel in its chronicle`() = runBlocking { + seedSignedWork() + recordEverythingApplied() + val project = assertNotNull(seededProject) + + val payloads = chronicledPayloads() + + assertTrue( + payloads.any { it.kind == ProjectEvent.KIND && it.id == project.id }, + "a late member should be told what the room's work is for" + ) + assertEquals( + 1, + payloads.count { it.kind == ProjectArtifactVersionEvent.KIND }, + "the project's source version should travel with it" + ) + assertEquals( + 1, + payloads.count { it.kind == ProjectTranslationArtifactVersionEvent.KIND }, + "the project's translation should travel with it" + ) + } + + @Test + fun `a chronicled project arrives in an order that applies`() = runBlocking { + seedSignedWork() + recordEverythingApplied() + + val kinds = chronicledPayloads().map { it.kind } + + // The assertion that matters, and the reason projects went on the end of + // APPLY_ORDER rather than anywhere tidier: both memberships are foreign + // keys into the project and into a version, so either arriving first + // would be refused -- silently, on the one device that has nothing else + // to fall back on. + val project = kinds.indexOf(ProjectEvent.KIND) + assertTrue(project >= 0, "the project is in the chronicle") + + assertTrue( + kinds.indexOf(ProjectArtifactVersionEvent.KIND) > project && + kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND) > project, + "memberships must come after their project" + ) + assertTrue( + kinds.indexOf(ArtifactVersionEvent.KIND) < + kinds.indexOf(ProjectArtifactVersionEvent.KIND), + "a membership must come after the version it names" + ) + assertTrue( + kinds.indexOf(TranslationArtifactVersionEvent.KIND) < + kinds.indexOf(ProjectTranslationArtifactVersionEvent.KIND), + "a membership must come after the translation it names" + ) + } + + /** + * The one thing "no `toProjectEvent`" costs, stated rather than discovered. + * + * Every other kind in a chronicle has two sources: the event as the group + * signed it, and a rebuild from the row when the signed event predates the + * table that keeps them. Projects have only the first, because that rebuild + * exists for rooms older than schema v13 and projects arrive at v21 -- there + * is no project row anywhere whose event was never recorded. + * + * Except by accident: `recordSignedEvents` catches its own failure and lets + * the session complete, correctly, since the signature is valid whether or + * not this device could write it down. A project applied through that gap is + * a row this device can show and cannot chronicle, and it will not come + * back -- which is worth knowing is possible rather than worth preventing, + * since the alternative is failing a ceremony that succeeded. + */ + @Test + fun `a project is only ever chronicled off the record`() = runBlocking { + seedSignedWork() + + // Everything on file except the project, which is the shape that gap + // leaves behind. + record(*applied.filterNot { it.kind == ProjectEvent.KIND }.toTypedArray()) + + val project = assertNotNull(seededProject) + + assertNotNull( + db.mantraProjectDao().getProjectById(project.id), + "the row is here either way" + ) + assertTrue( + chronicledPayloads().none { it.id == project.id }, + "and without its signed event there is nothing to chronicle" + ) + } } From 9bd4216867db3b2b3ebdcabc618deb9f68138399 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:30:18 +0200 Subject: [PATCH 12/32] feat(projects): the repository reads a project and what is in it Phase 6 of docs/project-feature.md. Six methods on `MantraRepository`, with `DatabaseMantraRepository` behind them and `NO_OP` returning empty for previews. The two contents queries return the versions rather than the join rows. No screen wants a join row, and the DAO resolves it in one indexed query instead of leaving an N+1 in a view model. Both inverses are here too -- which projects a version is part of -- because an artifact's own screen is where "what is this for" gets asked. Nothing in `MantraDao`. That path -- `addDialect`, `addArtifactVersion`, `rumorOf`, `submitToGroup` -- is the old submit flow, reached now only from its own test, and every entity a member creates is proposed for signing instead. An `addProject` there would be a second way to create a project that bypasses the quorum, which is the thing this feature is for. No query by creator or owner. Both are columns and a screen that wants them has the row; `getProjectsOwnedBy` would be a query with one caller and an index to go with it, and a room's project list is short. Add it when a screen asks. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseMantraRepository.kt | 30 ++++++++++++ .../compose/repository/MantraRepository.kt | 46 +++++++++++++++++++ 2 files changed, 76 insertions(+) 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 74552762..8eafb679 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 @@ -9,6 +9,7 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk 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.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk @@ -41,6 +42,35 @@ class DatabaseMantraRepository( override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List = database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId) + override suspend fun getProjects(chatRoomId: String): List = + database.mantraProjectDao().getProjectsByChatRoomId(chatRoomId) + + override suspend fun getProject(id: String): MantraProject? = + database.mantraProjectDao().getProjectById(id) + + override suspend fun getProjectArtifactVersions( + projectId: String + ): List = + database.mantraProjectArtifactVersionDao().getArtifactVersionsByProjectId(projectId) + + override suspend fun getProjectTranslationArtifactVersions( + projectId: String + ): List = + database.mantraProjectTranslationArtifactVersionDao() + .getTranslationArtifactVersionsByProjectId(projectId) + + override suspend fun getProjectsForArtifactVersion( + artifactVersionId: String + ): List = + database.mantraProjectArtifactVersionDao() + .getProjectsByArtifactVersionId(artifactVersionId) + + override suspend fun getProjectsForTranslationArtifactVersion( + translationArtifactVersionId: String + ): List = + database.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId(translationArtifactVersionId) + override suspend fun getChapter(id: String): MantraChapter? = database.mantraChapterDao().getChapterById(id) 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 677998d6..3d197ad5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MantraRepository.kt @@ -6,6 +6,7 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk 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.MantraTranslationChapter import press.mantra.compose.database.model.MantraTranslationChunk @@ -56,6 +57,31 @@ interface MantraRepository { suspend fun getDialect(id: String): MantraDialect? + suspend fun getProjects(chatRoomId: String): List + + suspend fun getProject(id: String): MantraProject? + + /** + * The source versions a project is translating from. + * + * The versions, not the join rows: no screen wants a join row, and resolving + * it here is one indexed query instead of an N+1 in a view model. + */ + suspend fun getProjectArtifactVersions(projectId: String): List + + /** The translations a project is producing. */ + suspend fun getProjectTranslationArtifactVersions( + projectId: String + ): List + + /** The inverse: which projects a source version is part of. */ + suspend fun getProjectsForArtifactVersion(artifactVersionId: String): List + + /** The inverse again, for a translation. */ + suspend fun getProjectsForTranslationArtifactVersion( + translationArtifactVersionId: String + ): List + suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, @@ -102,6 +128,26 @@ interface MantraRepository { override suspend fun getDialect(id: String): MantraDialect? = null + override suspend fun getProjects(chatRoomId: String): List = emptyList() + + override suspend fun getProject(id: String): MantraProject? = null + + override suspend fun getProjectArtifactVersions( + projectId: String + ): List = emptyList() + + override suspend fun getProjectTranslationArtifactVersions( + projectId: String + ): List = emptyList() + + override suspend fun getProjectsForArtifactVersion( + artifactVersionId: String + ): List = emptyList() + + override suspend fun getProjectsForTranslationArtifactVersion( + translationArtifactVersionId: String + ): List = emptyList() + override suspend fun addArtifactVersion( localChatRoom: LocalChatRoom, artifactId: HexKey, From 428026cdfd920f3ed9e70f81b8bf3d3510e85fa4 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:33:43 +0200 Subject: [PATCH 13/32] refactor(chapters): one scaffolding batch for every chapter, not one each Phase 5 of docs/chapter-uploads.md, and the third fix to something wrong today. AddChapterViewModel was right that translation scaffolding needs its own session -- a chapter's paragraph budget should not be spent on the group's dialects, and how long a chapter may be has nothing to do with how many languages it is read in. It was wrong that it needs one session per chapter, which only becomes visible above one chapter: twelve chapters in a group with three dialects is thirty-six join rows, which fit in a single batch, where the per-chapter shape opens twelve sessions to carry three rows each. Twenty-four proposals where thirteen will do. ChapterScaffolding.propose is now the one answer to what a chapter's scaffolding is, and both view models call it. Two copies of a cross product would be two chances to disagree about what a translation covers. It also fixes a latent bug one chapter could never show. The old code chunked the *translations* by MAX_BATCH_SIZE -- right by coincidence at one chapter, where the cross product and the translation list are the same length, and wrong at any more. 25 chapters in a three-dialect group is 75 rows, which the translation axis would have put in one batch of 75 and proposeSigningBatch would have thrown. Chunking the product is the generalisation, and the test for it is the only one here that fails against the per-chapter shape. Two smaller decisions. The batch takes one timestamp, the latest of the chapters' own, because that is the moment the act finished and the clock would be a third time unrelated to either end of it. And a session whose lead event cannot be read is left out rather than guessed at: a join row naming the wrong chapter is worse than a missing one, because the catch-up can find a missing one. Failure policy unchanged and for the reason it already gave: logged and swallowed, since the chapters are what was asked for. At N the argument is stronger, not weaker -- one cross-product batch is only as available as its worst item. 970 tests, no failures. Co-Authored-By: Claude Opus 5 --- .../compose/managers/ChapterScaffolding.kt | 127 +++++++ .../ui/view/model/AddChapterViewModel.kt | 85 ++--- .../ui/view/model/UploadChaptersViewModel.kt | 23 ++ .../managers/ChapterScaffoldingJvmTest.kt | 330 ++++++++++++++++++ docs/chapter-uploads.md | 38 +- 5 files changed, 535 insertions(+), 68 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt new file mode 100644 index 00000000..bc53680a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChapterScaffolding.kt @@ -0,0 +1,127 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository + +/** + * Asking the group to scaffold newly proposed chapters into every translation + * of their version. + * + * A translation chapter carries no words -- it is `(translation, chapter, + * position)` -- and it exists so a translated chunk has somewhere to hang. See + * [TranslationScaffold] for the rows themselves; this is the part that reads + * the version, finds the chapters as the group will author them, and opens the + * sessions. + * + * **Its own session, not more items on the chapter's.** A chapter is capped at + * [TranslationScaffold] -- really at `FrostSigningManager.MAX_BATCH_SIZE` minus + * the chapter itself -- and every dialect the group works in would take one of + * those places away. How long a chapter may be and how many languages it is + * read in have nothing to do with each other, and a shared cap would make the + * first move whenever somebody did the second. + * + * **But one session for all of them, not one per chapter.** This is what the + * upload needed and what `AddChapterViewModel` had wrong for a single chapter + * only by not noticing: twelve chapters in a group with three dialects is + * thirty-six join rows, which fit in one batch, and the per-chapter shape would + * have opened twelve sessions to carry three rows each. Twenty-four proposals + * where thirteen will do. + * + * **Failure is logged and swallowed**, as it was before. The chapters have + * already been proposed and are the thing that was asked for; losing them + * because their scaffolding could not be opened would be the wrong way round. A + * join row naming a chapter that never reached a quorum fails a foreign key on + * the way in, is logged by `applySignedEvent`, and never becomes a row -- so the + * catch-up on the translation is what fills the gap afterwards. + */ +object ChapterScaffolding { + + private const val TAG = "ChapterScaffolding" + + /** + * A chapter that has just been put to the group, named by the session that + * will sign it. + * + * The session rather than the chapter, because there is no chapter yet. Its + * id is settled at proposal time -- it is the hash the signers are putting + * their shares behind -- and [FrostSigningRepository.unsignedEvents] is how + * to read it while the session is still running. + */ + data class ProposedChapter( + val sessionId: String, + val index: Int, + ) + + /** + * Opens as few sessions as will carry the join rows for [chapters]. + * + * Chunked over the **cross product**, not over the translations. With one + * chapter the two are the same and the old code was right by coincidence; + * with twelve, chunking the translations would propose twelve chapters' + * worth of rows in one batch per sixty-four dialects, which is the wrong + * axis and overflows at the fifth chapter of a three-dialect group. + * + * Returns the sessions opened, which is nothing the caller has to act on -- + * it is there so a test can count them. + */ + suspend fun propose( + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + artifactVersionId: String, + chapters: List, + ): List { + if (chapters.isEmpty()) return emptyList() + + return runCatching { + val translations = mantraRepository + .getTranslationsForArtifactVersion(artifactVersionId) + .ifEmpty { return emptyList() } + + // The chapters as the group will author them. A session whose lead + // event cannot be read is left out rather than guessed at: a join + // row naming the wrong chapter is worse than one that is missing, + // because the catch-up can find a missing one. + val sources = chapters.mapNotNull { proposed -> + val lead = frostSigningRepository + .unsignedEvents(frostSigningRepository.getItems(proposed.sessionId)) + .firstOrNull() + ?: return@mapNotNull null + + TranslationScaffold.SourceChapter(id = lead.id, index = proposed.index) to + lead.createdAt + } + if (sources.isEmpty()) return emptyList() + + // One timestamp for the lot, so a scaffolding proposed as one act + // reads as one. The latest of the chapters' own, which is the moment + // the act finished -- not the clock, which would be a third time + // unrelated to either end of it. + val createdAt = sources.maxOf { it.second } + + val rows = TranslationScaffold.chaptersOf( + translationArtifactVersionIds = translations.map { it.id }, + chapters = sources.map { it.first }, + createdAt = createdAt, + ) + + rows.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { batch -> + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = batch, + )?.id + } + }.onFailure { error -> + Logger.withTag(TAG).e( + "Proposed ${chapters.size} chapter(s) but could not ask for them in this " + + "version's translations", + error, + ) + }.getOrDefault(emptyList()) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index fc529e3f..bea91925 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -19,8 +19,8 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.managers.ChapterIndexing +import press.mantra.compose.managers.ChapterScaffolding import press.mantra.compose.managers.FrostSigningManager -import press.mantra.compose.managers.TranslationScaffold import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChapterProposal import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -198,36 +198,18 @@ class AddChapterViewModel( /** * Asks the group to scaffold this chapter into every translation of the - * version, in sessions of their own. + * version, in a session of its own. * - * Its own session rather than more items on the chapter's, because the two - * would otherwise compete for one batch: a chapter is capped at - * [MAX_CHUNKS_PER_CHAPTER] paragraphs, and every dialect the group works in - * would take one of those places away. How long a chapter may be and how - * many languages it is read in have nothing to do with each other, and a - * shared cap would make the first move whenever somebody else did the - * second. + * The reasoning and the failure policy are [ChapterScaffolding]'s; this is + * the one-chapter call into it. Sharing that code with the upload screen is + * the point -- there is one answer to what a chapter's scaffolding is, and + * two copies of a cross product would be two chances to disagree about what + * a translation covers. * - * Sessions run independently -- every message carries the session it - * belongs to, and each mints its own nonce material -- so a second one - * opened here costs the admins another approval and nothing else. - * - * **What a separate session gives up.** A batch is all-or-nothing; two - * sessions are not. If the chapter fails to reach a quorum while this one - * succeeds, these are valid signatures over rows naming a chapter nobody - * has: they fail a foreign key on the way in, are logged by - * `applySignedEvent`, and never become rows. Harmless, and not recoverable - * either -- a re-proposed chapter is a different id -- so the catch-up on - * the translation is what fills the gap afterwards. - * - * The chapter is named as the group will author it: [chapterSessionId]'s - * first item is the unsigned chapter, whose id is the hash its signers are - * putting their shares behind, and it is settled at proposal time rather - * than when the quorum arrives. - * - * Failure here is logged and swallowed. The chapter has already been - * proposed and is the thing that was asked for; losing it because its - * scaffolding could not be opened would be the wrong way round. + * Called before navigating, not after: the route this screen sits on is + * popped on success, which clears this view model and takes + * `viewModelScope` with it, so work launched afterwards would be cancelled + * somewhere in the middle. */ private suspend fun scaffoldIntoTranslations( localChatRoom: LocalChatRoom, @@ -235,40 +217,19 @@ class AddChapterViewModel( chapterIndex: Int, chapterSessionId: String, ) { - runCatching { - val translations = mantraRepository - .getTranslationsForArtifactVersion(artifactVersion.id) - .ifEmpty { return@runCatching } - - val chapter = frostSigningRepository - .unsignedEvents(frostSigningRepository.getItems(chapterSessionId)) - .firstOrNull() ?: return@runCatching - - // One session per MAX_BATCH_SIZE translations. A group with more - // dialects than a batch holds is not a group this should refuse; - // it is one that answers twice. - translations.chunked(FrostSigningManager.MAX_BATCH_SIZE).forEach { batch -> - frostSigningRepository.proposeSigningBatch( - localChatRoom = localChatRoom, - userPublicKey = activeUserPublicKey, - events = TranslationScaffold.chaptersOf( - translationArtifactVersionIds = batch.map { it.id }, - chapters = listOf( - TranslationScaffold.SourceChapter( - id = chapter.id, - index = chapterIndex, - ) - ), - createdAt = chapter.createdAt, - ) + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + artifactVersionId = artifactVersion.id, + chapters = listOf( + ChapterScaffolding.ProposedChapter( + sessionId = chapterSessionId, + index = chapterIndex, ) - } - }.onFailure { error -> - logger.e( - "Proposed the chapter but could not ask for it in this version's translations", - error - ) - } + ), + ) } companion object { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt index 6ea233e5..5558c7c3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt @@ -18,6 +18,7 @@ import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.files.PickedFile import press.mantra.compose.managers.ChapterIndexing +import press.mantra.compose.managers.ChapterScaffolding import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent @@ -208,6 +209,7 @@ class UploadChaptersViewModel( var index = nextIndex(artifactVersion) var proposed = 0 var failed = 0 + val scaffolded = mutableListOf() for (id in queue) { val row = (_uiState.value as? UploadChaptersUIState.Loaded) @@ -239,6 +241,10 @@ class UploadChaptersViewModel( if (session != null) { markProposed(id, session.id) + scaffolded += ChapterScaffolding.ProposedChapter( + sessionId = session.id, + index = index, + ) proposed++ index++ } else { @@ -247,6 +253,23 @@ class UploadChaptersViewModel( } } + // One batch for every chapter that went, not one per chapter. Twelve + // chapters in a three-dialect group is thirty-six join rows, which + // fit in a single session; the per-chapter shape would have opened + // twelve of them to carry three rows each. + // + // After the loop rather than inside it, so a chapter that failed to + // propose contributes no scaffolding and the ones that succeeded + // still get theirs. + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + artifactVersionId = artifactVersion.id, + chapters = scaffolded, + ) + passes++ updateLoaded { it.copy( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt new file mode 100644 index 00000000..e1156ff6 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterScaffoldingJvmTest.kt @@ -0,0 +1,330 @@ +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.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +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.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +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.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.text.Markdown + +/** + * How many sessions a group is asked for when chapters arrive together. + * + * The scaffolding has to be its own session -- a chapter's paragraph budget + * should not be spent on the group's dialects -- but it does not have to be one + * session *per chapter*, and that only becomes visible above one chapter. The + * first test here is the arithmetic the upload rests on: twelve chapters and + * three dialects is thirty-six join rows, which is one batch rather than + * twelve. + */ +class ChapterScaffoldingJvmTest { + private val participants = 3 + private val threshold = 2 + + 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 val dialectId = "d".repeat(64) + private val artifactId = "a1".padEnd(64, '0') + private val artifactVersionId = "b".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + private lateinit var mantraRepository: MantraRepository + private lateinit var frostSigningRepository: FrostSigningRepository + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + mantraRepository = DatabaseMantraRepository(db, scope) + frostSigningRepository = DatabaseFrostSigningRepository(db, scope) + + 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], + ) + ) + } + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = "", + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://example.test/mockingbird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = "", + ) + ) + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + chatRoomId = roomId, + signature = "", + ) + ) + + room = LocalChatRoom(chatRoom = chatRoom) + } + + private suspend fun addTranslations(count: Int) { + (0 until count).forEach { ordinal -> + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = "translation$ordinal".padEnd(64, '0'), + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Translation $ordinal", + visibility = "private", + license = "cc", + signature = "", + chatRoomId = roomId, + ) + ) + } + } + + /** Proposes [count] chapters the way the upload screen does, in index order. */ + private suspend fun proposeChapters(count: Int): List = + (0 until count).map { index -> + val originalText = "Paragraph one.\n\nParagraph two." + val session = FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = "Chapter $index", + originalText = originalText, + index = index, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ), + dependents = ChunkEvent::splitOf, + ) + ChapterScaffolding.ProposedChapter(sessionId = session.id, index = index) + } + + private suspend fun scaffold(chapters: List) = + ChapterScaffolding.propose( + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + localChatRoom = room, + userPublicKey = proposer, + artifactVersionId = artifactVersionId, + chapters = chapters, + ) + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + @Test + fun `twelve chapters and three dialects is one scaffolding session`() = runBlocking { + openDevice() + addTranslations(3) + + val sessions = scaffold(proposeChapters(12)) + + // The arithmetic the upload rests on. Thirty-six join rows fit in one + // batch; a session per chapter would have been twelve of them carrying + // three rows each. + assertEquals(1, sessions.size) + assertEquals(36, itemEvents(sessions.single()).size) + } + + @Test + fun `every row is a translation chapter naming a chapter the group will author`() = + runBlocking { + openDevice() + addTranslations(2) + + val chapters = proposeChapters(2) + val sessions = scaffold(chapters) + val rows = itemEvents(sessions.single()) + + assertTrue(rows.all { it.kind == TranslationChapterEvent.KIND }) + + // The ids come from the chapters' own sessions, which is the only + // place they exist before a quorum signs: a hash over the group's + // key at the room's path. + val chapterIds = chapters.map { proposed -> + itemEvents(proposed.sessionId).first().id + } + val named = rows.map { row -> + TranslationChapterEvent( + row.id, row.pubKey, row.createdAt, row.tags, row.content, row.sig + ).chapterId() + } + + assertEquals(chapterIds.toSet(), named.toSet()) + } + + @Test + fun `a chapter keeps its own position, not the list's`() = runBlocking { + openDevice() + addTranslations(1) + + // A catch-up proposes a subset, so counting the list would renumber the + // chapters it happens to be filling in. + val sessions = scaffold( + listOf( + ChapterScaffolding.ProposedChapter( + sessionId = proposeChapters(1).single().sessionId, + index = 7, + ) + ) + ) + + val row = itemEvents(sessions.single()).single() + assertEquals( + 7, + TranslationChapterEvent( + row.id, row.pubKey, row.createdAt, row.tags, row.content, row.sig + ).index(), + ) + } + + @Test + fun `the cross product is what gets chunked, not the translations`() = runBlocking { + openDevice() + addTranslations(3) + + // 25 chapters x 3 dialects is 75 rows, which is two batches. Chunking + // the translations instead would have put all 75 in one and thrown -- + // which is the bug this generalisation fixes, and it only appears above + // one chapter. + val sessions = scaffold(proposeChapters(25)) + + assertEquals(2, sessions.size) + assertEquals( + FrostSigningManager.MAX_BATCH_SIZE, + itemEvents(sessions.first()).size, + ) + assertEquals(75 - FrostSigningManager.MAX_BATCH_SIZE, itemEvents(sessions.last()).size) + } + + @Test + fun `a version with no translations is asked for nothing`() = runBlocking { + openDevice() + + assertEquals(emptyList(), scaffold(proposeChapters(3))) + } + + @Test + fun `no chapters is asked for nothing`() = runBlocking { + openDevice() + addTranslations(3) + + assertEquals(emptyList(), scaffold(emptyList())) + } +} diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index ec61cabe..eee3aa51 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -557,14 +557,32 @@ during an upload. `TranslationScaffold.chaptersOf(translationArtifactVersionIds, chapters, createdAt)` already takes a list and already builds the full cross product in translation-then-reading order. So lift the scaffolding out of the per-chapter -loop: collect each chapter's `SourceChapter(id, index)` from its session's -unsigned lead event, and after the last chapter is proposed, propose the whole -cross product in `chunked(MAX_BATCH_SIZE)` batches — which is what `addChapter` -already does across *translations* and what -`TranslationArtifactVersionDetailViewModel` does across chapters. +loop into `ChapterScaffolding.propose`, which both view models now call: +collect each chapter's `SourceChapter(id, index)` from its session's unsigned +lead event, and after the last chapter is proposed, propose the whole cross +product in `chunked(MAX_BATCH_SIZE)` batches. Twelve chapters × three dialects: from 12 scaffolding sessions to 1. +**And it fixes a latent bug that one chapter could never show.** The old code +chunked the *translations* — right by coincidence at one chapter, where the +cross product and the translation list are the same length, and wrong at any +more: 25 chapters in a three-dialect group is 75 rows, which the translation +axis would have put in a single batch of 75 and `proposeSigningBatch` would have +thrown. Chunking the product is the generalisation, and the test for it is the +only one here that would fail against the per-chapter shape. + +Two smaller decisions the build had to make: + +- **One timestamp for the batch, the latest of the chapters' own.** + `chaptersOf` stamps every row with one `createdAt` so a scaffolding proposed + as one act reads as one; with N chapters there are N candidates. The latest is + the moment the act finished. Reading the clock instead would be a third time + unrelated to either end of it. +- **A session whose lead event cannot be read is left out, not guessed at.** A + join row naming the wrong chapter is worse than a missing one, because the + catch-up can find a missing one. + Keep the existing failure policy verbatim, including its reasoning: scaffolding failure is logged and swallowed, because the chapters are the thing that was asked for, and a scaffolding row naming a chapter that never reached quorum @@ -575,7 +593,15 @@ only as available as its worst item, and some of those chapters may genuinely fail. `AddChapterViewModel` keeps its one-chapter call into the same helper, so there -is one piece of code deciding what a chapter's scaffolding is. +is one piece of code deciding what a chapter's scaffolding is. The upload calls +it once, after its loop, with only the chapters that actually went — so a +chapter that failed to propose contributes no scaffolding and the ones that +succeeded still get theirs. + +**Tests:** `ChapterScaffoldingJvmTest`, six of them, over a real database and +real proposals. The arithmetic (12 × 3 = one session of 36 rows), the ids coming +from the chapters' own sessions, a chapter keeping its own index rather than the +list's, the cross-product chunking at 25 × 3 = 75, and the two empty cases. --- From f8a0ee1cdbb6bba54fa3a612eba18618f7932902 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:36:14 +0200 Subject: [PATCH 14/32] test(chapters): twelve proposals each keep their own summons Phase 6 of docs/chapter-uploads.md, which asked for three things to be checked rather than assumed. All three already hold, so this is their tests and the record of it. The proposal list already groups: a "Waiting for you" header over the sessions isAwaitingApproval picks out, "Everything else" under them, over a query already ordered newest first. The chapter summary is already `name - N words - N chunks` rather than "Chapter", so twelve rows are twelve distinguishable rows. And isAbout already compares frostSigningSessionId when both rows carry one, falling back to the clock only when either does not. The last of those is the one with a history -- two concurrent sessions broke it once, which is why the column exists -- so it is now tested at twelve, with the answers given in the opposite order to the asking, so that neither the clock nor the list order could produce the right answer by accident. Plus one proposal failing and settling only its own row, which is the transcript's version of why an upload is N sessions and not one. Noted for Phase 8 rather than fixed here: twenty open chapter proposals is roughly 900 KB of unsignedEventJson parsed and summarised on every emission of that query, and summarize then re-splits each chapter's text to count its chunks. Phase 8 replaces that derived count with the session's own item count. Co-Authored-By: Claude Opus 5 --- .../model/TranscriptRequestStateTest.kt | 38 +++++++++++++++++ docs/chapter-uploads.md | 42 +++++++++++++------ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt index 4dbf4788..3905f187 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/TranscriptRequestStateTest.kt @@ -182,4 +182,42 @@ class TranscriptRequestStateTest { assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript)) } + + @Test + fun `twelve open proposals each keep their own summons`() { + // An upload of twelve files opens twelve sessions at once, interleaved in + // one stretch of transcript. The clock fallback would have the first + // answer drop every later request, which is the failure two concurrent + // sessions already had once -- and twelve is where a path that still + // falls back to the clock would show. + val requests = (1..12).map { n -> + line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 100L + n, session = "session-" + n) + } + + // Answered in the opposite order to the asking, so neither the clock nor + // the list order could produce the right answer by accident. + val answers = listOf(12, 7, 1).map { n -> + line(ChatMessage.TYPE_FROST_NONCE, at = 200L + n, session = "session-" + n) + } + + val answered = ChatMessage.answeredRequests(requests + answers) + + assertEquals( + setOf(requests[11].id, requests[6].id, requests[0].id), + answered, + ) + } + + @Test + fun `one proposal failing settles only itself`() { + val requests = (1..12).map { n -> + line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 100L + n, session = "session-" + n) + } + val failure = line(ChatMessage.TYPE_FROST_FAILED, at = 300, session = "session-5") + + // The other eleven chapters are still in front of the group. A batch is + // all-or-nothing within a session and nothing across them, which is the + // whole reason an upload is N sessions rather than one. + assertEquals(setOf(requests[4].id), ChatMessage.settledRequests(requests + listOf(failure))) + } } diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index eee3aa51..d5695041 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -615,23 +615,39 @@ The transcript and the proposal list were built for two concurrent sessions — scaffolding were open together. Twelve is the same problem at a scale that will find whatever that fix missed. -What to check rather than assume: +What to check rather than assume — and **all three already held**, so this +phase is its tests and this paragraph: -- `ProposalListScreen` with twenty open sessions: the ones waiting on the reader - gather at the top, and `ProposedEvent.summarize` gives a chapter a name rather - than "Event of kind 30302". Chapters are summarised today; confirm the - summary is the chapter's *name*, since twelve rows reading "Chapter" are - twelve rows a member cannot choose between. -- Twelve "Review" lines in the transcript, each resolving its own session. - `answeredRequests`/`settledRequests` read `frostSigningSessionId` when both - rows have one; twelve is where a fallback-to-clock path would show. -- Twelve approval gates. No batching of the gate — that is the one thing the - batch-signing note refuses, and for the same reason: "a member who cannot - scroll the whole list should not be able to approve it". What can be done is - make the *list* cheap to work through, which is the point above. +- **The list already groups.** `ProposalListScreen` renders a "Waiting for you" + header over the sessions `FrostSigningManager.isAwaitingApproval` picks out and + an "Everything else" header under them, over a query that is already + `ORDER BY createdAt DESC`. Twelve proposals arrive sorted and the three that + need the reader are at the top. +- **The summary already names the chapter.** `ProposedEvent.summarize`'s chapter + branch is `name · N words · N chunks`, not "Chapter" — so twelve rows are + twelve distinguishable rows. +- **`isAbout` already matches on the session.** `answeredRequests` and + `settledRequests` compare `frostSigningSessionId` when both rows carry one and + fall back to the clock only when either does not. Now tested at twelve, with + the answers given in the opposite order to the asking so that neither the + clock nor the list order could produce the right answer by accident, plus one + failure settling only its own row. +- **Twelve approval gates, unbatched.** Unchanged by design: the gate shows every + event rather than a count, because "a member who cannot scroll the whole list + should not be able to approve it". No new `ChatMessage` type, no new set to add to. +### One thing N does make worse, and it is Phase 8's to fix + +`observeSessionsForChatRoom` carries every session's items, and a chapter item's +`unsignedEventJson` holds the whole markdown. Twenty open chapter proposals is +roughly 900 KB of JSON parsed and summarised **on every emission** — and +`summarize` then re-splits each chapter's text to count its chunks, which is the +derived count Phase 8 replaces with the session's own item count. Fixing it +there removes the re-split; the parse is inherent to the query and is the price +of the list showing what each proposal signs without one query per row. + --- ## Phase 7 — the splitter, which is a decision about translation From 3912631b2e502321a6ff4b1f732dcbbd251e1f1a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:36:42 +0200 Subject: [PATCH 15/32] feat(projects): a room lists its projects, and one can be opened Phase 7a of docs/project-feature.md -- the read path. The write path and the screen for adding to an existing project follow. The room's project section was already there as a stub: a heading, a permanent "No projects exists in this group", and a button to `ImplementationPendingRoute("Add new project")`, under a `// TODO: Add projects...`. That is what this replaces, so the strings it was already using are reused rather than added beside near-identical ones -- which is also how the duplicate `projects` entry I first wrote got caught. `ProjectDetailScreen` shows the contents and then who the project belongs to. The second half is the point of the screen as much as the first: the creator and the owners are claims the group put its signature to, nobody else checks them, and the signed event is the only place they exist. So they are read here after the fact, resolved to profiles in the view model -- a composable cannot await a database, and these are a handful of keys read together. An owner that is the room's own key reads "This group" rather than printing the room id back at a member standing inside it, and sorts first, so the ordinary single-owner case is one unremarkable line and an outside owner is the one that stands out. M3 budgets met: spacing off the scale, strings in the catalogue in sentence case, `NavigateBackButton` on a pushed screen, `readableContent()` on the content root, all four states through `ScreenStateTransition`, every icon with a decided description. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 8 + .../ui/composable/ChatRoomDetailScreen.kt | 67 ++-- .../ui/composable/ProjectDetailScreen.kt | 319 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 23 ++ .../navigation/routes/ProjectDetailRoute.kt | 11 + .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../ui/view/model/ProjectDetailViewModel.kt | 94 ++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 4 + .../ui/view/state/ProjectDetailUIState.kt | 41 +++ 9 files changed, 542 insertions(+), 26 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45be57b2..d58d615e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -20,6 +20,7 @@ an em dash or a comma. The compose resources plugin reports that only as "XML file ... is not valid. Check the file content." --> + Add project Mantra Add a dialect the group can translate into Add artifact to library @@ -58,6 +59,13 @@ Create project Create the admin room Agree the group's signing key + Created by + No source versions in this project yet. + No translations in this project yet. + This group + Owners + Project detail + Source versions The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat. The group has agreed what the admin room will sign with. The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry. 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 0689fb34..1e489436 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 @@ -85,6 +85,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.AddDialectRoute 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 import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.parent_group @@ -468,42 +469,56 @@ fun ChatRoomDetailScreen( HorizontalDivider() } - // TODO: Add projects... - item { - // Artifacts + // Projects -- what the library above is for Text( text = stringResource(Res.string.projects), style = MaterialTheme.typography.labelMedium ) } - item { - Text(stringResource(Res.string.no_projects_exists_in_this_group)) - } - - item { - TextButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Add new project" + if (chatRoomDetailUIState.projects.isEmpty()) { + item { + Text(stringResource(Res.string.no_projects_exists_in_this_group)) + } + } else { + items( + items = chatRoomDetailUIState.projects, + key = { project -> project.id } + ) { project -> + Card( + onClick = { + onNavigateToRoute.invoke( + ProjectDetailRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Schema, + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text(text = project.name) + }, + supportingContent = { + Text(text = project.description) + } ) } - ) { - - Icon( - Icons.Default.Schema, - contentDescription = "create new projec" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.create_project)) } } 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 new file mode 100644 index 00000000..7cef5aa1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProjectDetailScreen.kt @@ -0,0 +1,319 @@ +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.height +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.Article +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.created_by +import mantra.composeapp.generated.resources.details +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 +import mantra.composeapp.generated.resources.owners +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.source_versions +import mantra.composeapp.generated.resources.translations +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.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +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.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.ProjectDetailViewModel +import press.mantra.compose.ui.view.state.ProjectDetailUIState + +/** + * What a body of work is, and who it belongs to. + * + * The two people rows are not decoration. A project's creator and owners are + * claims the group put its signature to -- nobody else checks them, and the + * event is the only place they exist -- so this screen is where a member reads + * what was agreed after the fact. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectDetailScreen( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + relayHint: String?, + initialProjectDetailUIState: ProjectDetailUIState = ProjectDetailUIState.Loading, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val projectDetailViewModel: ProjectDetailViewModel = viewModel( + factory = ProjectDetailViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProjectDetailUIState = initialProjectDetailUIState, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + ScreenStateTransition(projectDetailViewModel.projectDetailUIState) { uiState -> + when (val projectDetailUIState = uiState) { + is ProjectDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = projectDetailUIState.message) + } + } + + is ProjectDetailUIState.Loaded -> { + val project = projectDetailUIState.project + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(project.name) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (project.description.isNotBlank()) { + item { Text(project.description) } + item { HorizontalDivider() } + } + + // Source versions + item { + Text( + text = stringResource(Res.string.source_versions), + style = MaterialTheme.typography.labelMedium + ) + } + if (projectDetailUIState.sourceVersions.isEmpty()) { + item { + Text(stringResource(Res.string.no_source_versions_in_this_project)) + } + } else { + items( + items = projectDetailUIState.sourceVersions, + key = { version -> version.id } + ) { version -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Article, + contentDescription = Decorative + ) + }, + overlineContent = { + Text(stringResource(Res.string.version)) + }, + headlineContent = { Text(version.versionLabel) } + ) + } + } + } + item { HorizontalDivider() } + + // Translations + item { + Text( + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium + ) + } + if (projectDetailUIState.translations.isEmpty()) { + item { + Text(stringResource(Res.string.no_translations_in_this_project)) + } + } else { + items( + items = projectDetailUIState.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) } + ) + } + } + } + item { HorizontalDivider() } + + // Who it belongs to + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) + } + item { + val thisGroup = stringResource(Res.string.owned_by_this_group) + + Card { + DetailRow( + label = stringResource(Res.string.created_by), + value = describe( + publicKey = project.creatorPublicKey, + people = projectDetailUIState.people, + chatRoomId = chatRoomId, + thisGroup = thisGroup, + ) + ) + + // The room first, so the ordinary single-owner + // case is one unremarkable line and an outside + // owner is the one that stands out. + project.ownerPublicKeySet() + .sortedBy { if (it == chatRoomId) 0 else 1 } + .forEach { owner -> + DetailRow( + label = stringResource(Res.string.owners), + value = describe( + publicKey = owner, + people = projectDetailUIState.people, + chatRoomId = chatRoomId, + thisGroup = thisGroup, + ) + ) + } + } + } + } + } + } + + ProjectDetailUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.project_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialProjectDetailUIState == ProjectDetailUIState.Loading) { + projectDetailViewModel.initiateProjectDetail() + } + } +} + +/** + * A key as a person, as this group, or as a short hash -- in that order. + * + * The room's own key reads as the group rather than as its id: a member + * standing inside the room does not need it spelled back at them, and "this + * group" is what the owner actually is. + */ +private fun describe( + publicKey: HexKey, + people: Map, + chatRoomId: String, + thisGroup: String, +): String = when { + publicKey == chatRoomId -> thisGroup + else -> people[publicKey]?.humanReadableNameOrPubkey() ?: publicKey.shortened() +} + +@ConformancePreviews +@Composable +private fun ProjectDetailScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProjectDetailScreen( + activeUserPublicKey = "a".repeat(64), + projectId = "p".repeat(64), + chatRoomId = "b".repeat(64), + relayHint = null, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_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 92f022d0..99c08773 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 @@ -142,11 +142,13 @@ import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute 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.TranslateChunkRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.ArtifactDetailScreen +import press.mantra.compose.ui.composable.ProjectDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen import press.mantra.compose.ui.composable.AddDialectScreen import press.mantra.compose.ui.composable.FrostSigningScreen @@ -1219,6 +1221,27 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProjectDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + projectId = route.projectId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + nostrRepository = databaseNostrRepository, + 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/ProjectDetailRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt new file mode 100644 index 00000000..ac483695 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProjectDetailRoute.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class ProjectDetailRoute( + val activeUserPublicKey: String, + val projectId: String, + val chatRoomId: String, + val relayHint: String? +): Route() 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 3bfb3db2..260d5da1 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 @@ -78,6 +78,7 @@ class ChatRoomDetailViewModel( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), dialects = mantraRepository.getDialects(chatRoomId), + projects = mantraRepository.getProjects(chatRoomId), groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), 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 new file mode 100644 index 00000000..616a2cf3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProjectDetailViewModel.kt @@ -0,0 +1,94 @@ +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.database.model.Profile +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProjectDetailUIState + +class ProjectDetailViewModel( + val projectId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialProjectDetailUIState: ProjectDetailUIState, + val mantraRepository: MantraRepository, + val nostrRepository: NostrRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var projectDetailUIState: ProjectDetailUIState by mutableStateOf(initialProjectDetailUIState) + private set + + private val logger = Logger.withTag(TAG) + + fun initiateProjectDetail() { + logger.d("initiateProjectDetail: $projectId") + viewModelScope.launch(Dispatchers.IO) { + val project = mantraRepository.getProject(projectId) + + projectDetailUIState = if (project == null) { + ProjectDetailUIState.Error("Couldn't find the project") + } else { + ProjectDetailUIState.Loaded( + project = project, + sourceVersions = mantraRepository.getProjectArtifactVersions(projectId), + translations = mantraRepository.getProjectTranslationArtifactVersions(projectId), + // The creator and the owners, resolved once here. A screen + // cannot await a database per row, and these are a handful + // of keys read together. + people = peopleBehind( + project.ownerPublicKeySet() + project.creatorPublicKey + ), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + private suspend fun peopleBehind(publicKeys: Set): Map = + publicKeys.mapNotNull { publicKey -> + nostrRepository.getProfileWithPublicKey(publicKey)?.let { publicKey to it } + }.toMap() + + companion object { + private const val TAG = "ProjectDetailViewModel" + + fun factory( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + relayHint: String?, + initialProjectDetailUIState: ProjectDetailUIState = ProjectDetailUIState.Loading, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProjectDetailViewModel( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProjectDetailUIState = initialProjectDetailUIState, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} 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 80f08814..e8fbf0f3 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 @@ -6,6 +6,7 @@ import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.GroupSignedEvent 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.intermdiate.LocalChatRoom sealed interface ChatRoomDetailUIState { @@ -13,6 +14,9 @@ sealed interface ChatRoomDetailUIState { val localChatRoom: LocalChatRoom, val artifacts: List = emptyList(), val dialects: List = emptyList(), + + /** What the room's library is being used for. */ + val projects: List = emptyList(), /** * What the group has said this room signs with, or null while it has * said nothing. 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 new file mode 100644 index 00000000..bb21e6c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProjectDetailUIState.kt @@ -0,0 +1,41 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.Profile + +sealed interface ProjectDetailUIState { + data class Loaded( + val project: MantraProject, + val sourceVersions: List = emptyList(), + val translations: List = emptyList(), + + /** + * The profiles behind the creator and the owners, where this device has + * them. + * + * Resolved in the view model rather than looked up as each row draws: + * the screen shows a handful of keys and a composable cannot await a + * database. A key with no profile here is shown shortened, which is what + * the rest of the app does with a stranger. + */ + val people: Map = emptyMap(), + + /** + * Whether this device holds a share of the group's key. + * + * Adding to a project is a proposal, and a device that cannot sign + * cannot open one -- so the buttons say so rather than failing when + * pressed. + */ + val canSign: Boolean = false, + ): ProjectDetailUIState + + data class Error( + val message: String + ): ProjectDetailUIState + + data object Loading: ProjectDetailUIState +} From 54249e3c4f49032210807cc834f1eba6e7c09a95 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:42:28 +0200 Subject: [PATCH 16/32] feat(projects): a member can ask the group to sign a project into being Phase 7b of docs/project-feature.md -- the write path. The room's "Create project" button now opens a form instead of `ImplementationPendingRoute`. Nothing is created here. What goes out is a proposal, and the project appears on every member's device at once -- authored by the room's own key -- when enough members sign. That is the difference from submitting one: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social; a signature is the group saying it, and it takes a quorum to say. One session over the project and its contents. The memberships cannot be built by this screen, because each carries the project's id and that is a hash over the group's key at the room's derivation path -- it does not exist until the proposal is made. So the project leads and the memberships are derived from it, the same shape `AddArtifactViewModel` uses for an artifact and its first version. The creator is not a field: it is whoever is filling the form in, the view model already has it, and a picker would only let somebody credit the wrong person for something the group is about to sign. The owners are a field, pre-filled with this group. Most projects never touch it and that is why it is pre-filled rather than hidden -- a group making something for somebody else needs the question asked, and a group that is not needs to see the answer it is giving. Two guards, both because the alternative is silent: an entry that is not 64 characters is refused on the way in, since `ContributorTag.parse` would drop it and the owner would vanish between the form and the signature; and the last owner cannot be removed, since `ProjectEvent.build` refuses an empty set and a button that throws is worse than one that is off. A project with no contents is allowed, unlike an artifact with no version. An artifact with no version is inert because nothing can attach to it; an empty project is a name waiting for work. M3 budgets met. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 10 + .../compose/ui/composable/AddProjectScreen.kt | 503 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 26 + .../ui/composable/navigation/MantraNavHost.kt | 34 ++ .../navigation/routes/AddProjectRoute.kt | 10 + .../ui/view/model/AddProjectViewModel.kt | 206 +++++++ .../ui/view/state/AddProjectUIState.kt | 32 ++ 7 files changed, 821 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d58d615e..552fe3a7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -20,7 +20,9 @@ an em dash or a comma. The compose resources plugin reports that only as "XML file ... is not valid. Check the file content." --> + Add an owner Add project + An owner is a 64-character public key. Mantra Add a dialect the group can translate into Add artifact to library @@ -60,12 +62,19 @@ Create the admin room Agree the group's signing key Created by + What this project is + e.g. The four gospels into Sesotho + Name of project No source versions in this project yet. No translations in this project yet. This group + Owner public key Owners Project detail + Propose project + Remove owner Source versions + Ask the group to sign a project into being The group is agreeing what the admin room will sign with. It takes %1$s of %2$s members, and the request is in this chat. The group has agreed what the admin room will sign with. The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry. @@ -220,6 +229,7 @@ Shared key Signing key The key this room signs as, and the ceremony it came from. + This group has no library yet, so a project would have nothing in it. Add an artifact first. What the group signed This room signs as Derivation path diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt new file mode 100644 index 00000000..d93e4b2d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddProjectScreen.kt @@ -0,0 +1,503 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.add_an_owner +import mantra.composeapp.generated.resources.an_owner_is_a_64_character_public_key +import mantra.composeapp.generated.resources.describe_the_project +import mantra.composeapp.generated.resources.eg_the_four_gospels_into_sesotho +import mantra.composeapp.generated.resources.name_of_project +import mantra.composeapp.generated.resources.owned_by_this_group +import mantra.composeapp.generated.resources.owner_public_key +import mantra.composeapp.generated.resources.owners +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.propose_project +import mantra.composeapp.generated.resources.remove_owner +import mantra.composeapp.generated.resources.source_versions +import mantra.composeapp.generated.resources.start_a_project_in_this_group +import mantra.composeapp.generated.resources.this_group_has_no_library_to_build_a_project_from +import mantra.composeapp.generated.resources.translations +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.shortened +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.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +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 +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AddProjectViewModel +import press.mantra.compose.ui.view.state.AddProjectUIState + +/** + * Proposing a project: a name, what it is, who owns it, and what is in it. + * + * The creator is not a field. It is whoever is filling the form in, the view + * model already knows it, and a picker would only let somebody credit the wrong + * person for work the group is about to put its signature to. + * + * The owners are a field, pre-filled with this group. Most projects never touch + * it, and that is the point of pre-filling rather than hiding: a group making + * something for somebody else needs the question asked, and a group that is not + * needs to see the answer it is giving. + */ +@OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) +@Composable +fun AddProjectScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val addProjectViewModel: AddProjectViewModel = viewModel( + factory = AddProjectViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddProjectUIState = initialAddProjectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + activeUserPublicKey = activeUserPublicKey, + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + ) + ) + + ScreenStateTransition(addProjectViewModel.addProjectUIState) { uiState -> + when (val addProjectUIState = uiState) { + is AddProjectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addProjectUIState.message) + } + } + + is AddProjectUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val descriptionFieldState = rememberTextFieldState() + val ownerFieldState = rememberTextFieldState() + + // Pre-filled with the room, which is the common answer and a real + // value rather than an absence: "owned by us" and "owner unstated" + // must not be the same event. + val owners: SnapshotStateList = remember { + mutableStateListOf(chatRoomId) + } + val selectedSourceVersions = remember { mutableStateListOf() } + val selectedTranslations = remember { mutableStateListOf() } + + var nameEntered by remember { mutableStateOf(false) } + LaunchedEffect(nameFieldState.text) { + nameEntered = nameFieldState.text.isNotBlank() + } + + // A key that is not 64 hex characters is dropped by + // ContributorTag.parse, so it would vanish between this form and + // the signature. Caught on entry instead. + val typedOwner = ownerFieldState.text.toString().trim() + val canAddOwner = typedOwner.length == OWNER_KEY_LENGTH && + typedOwner !in owners + + val canAddProject = nameEntered && + owners.isNotEmpty() && + addProjectUIState.canSign + + // imePadding: this screen has text fields, and without it the + // software keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + addProjectUIState.localChatRoom.RenderChatRoomTitleText() + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFab( + label = stringResource(Res.string.propose_project), + icon = { + Icon(Icons.Default.Add, contentDescription = Decorative) + }, + enabled = canAddProject, + onClick = { + addProjectViewModel.addProject( + localChatRoom = addProjectUIState.localChatRoom, + nameField = nameFieldState, + descriptionField = descriptionFieldState, + ownerPublicKeys = owners.toSet(), + sourceVersionIds = selectedSourceVersions.toList(), + translationIds = selectedTranslations.toList(), + onSuccess = { sessionId -> + // Onto the session rather than to + // the project. Nothing exists yet + // -- it appears when enough members + // sign -- so a detail screen for a + // row that is not there would read + // as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed project") + ) + } + ) + } + ) + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.space125 + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.start_a_project_in_this_group)) + + if (!addProjectUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "project into being. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = transparentOutline(), + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = Decorative + ) + }, + label = { Text(stringResource(Res.string.name_of_project)) }, + placeholder = { + Text(stringResource(Res.string.eg_the_four_gospels_into_sesotho)) + }, + ) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = descriptionFieldState, + colors = transparentOutline(), + leadingIcon = { + Icon(Icons.Default.Notes, contentDescription = Decorative) + }, + label = { Text(stringResource(Res.string.describe_the_project)) }, + ) + + // --- owners --- + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.owners), + style = MaterialTheme.typography.labelMedium + ) + + val thisGroup = stringResource(Res.string.owned_by_this_group) + + owners.forEach { owner -> + ListItem( + headlineContent = { + Text( + if (owner == chatRoomId) thisGroup else owner.shortened() + ) + }, + trailingContent = { + // The last one cannot go: ProjectEvent.build + // refuses an empty set, and a button that + // throws is worse than one that is off. + IconButton( + enabled = owners.size > 1, + onClick = { owners.remove(owner) } + ) { + Icon( + Icons.Default.Close, + contentDescription = stringResource( + Res.string.remove_owner + ) + ) + } + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier + .weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = ownerFieldState, + colors = transparentOutline(), + label = { Text(stringResource(Res.string.owner_public_key)) }, + isError = typedOwner.isNotEmpty() && !canAddOwner, + supportingText = { + if (typedOwner.isNotEmpty() && !canAddOwner) { + Text( + stringResource( + Res.string.an_owner_is_a_64_character_public_key + ) + ) + } + }, + ) + + IconButton( + enabled = canAddOwner, + onClick = { + owners.add(typedOwner) + ownerFieldState.clearText() + } + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.add_an_owner) + ) + } + } + + // --- what is in it --- + + if (addProjectUIState.sourceVersions.isEmpty()) { + Text( + text = stringResource( + Res.string.this_group_has_no_library_to_build_a_project_from + ), + style = MaterialTheme.typography.bodySmall + ) + } else { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.source_versions), + style = MaterialTheme.typography.labelMedium + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ) + ) { + addProjectUIState.sourceVersions.forEach { version -> + FilterChip( + selected = version.id in selectedSourceVersions, + onClick = { + if (version.id in selectedSourceVersions) { + selectedSourceVersions.remove(version.id) + } else { + selectedSourceVersions.add(version.id) + } + }, + leadingIcon = { + Icon( + Icons.Default.Article, + contentDescription = Decorative + ) + }, + label = { Text(version.versionLabel) } + ) + } + } + } + + if (addProjectUIState.translations.isNotEmpty()) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ) + ) { + addProjectUIState.translations.forEach { translation -> + FilterChip( + selected = translation.id in selectedTranslations, + onClick = { + if (translation.id in selectedTranslations) { + selectedTranslations.remove(translation.id) + } else { + selectedTranslations.add(translation.id) + } + }, + leadingIcon = { + Icon( + Icons.Default.Translate, + contentDescription = Decorative + ) + }, + label = { Text(translation.name) } + ) + } + } + } + } + } + } + + AddProjectUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.project_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialAddProjectUIState == AddProjectUIState.Loading) { + addProjectViewModel.initiateAddProject() + } + } +} + +/** What `ContributorTag.parse` demands of an owner's value. */ +private const val OWNER_KEY_LENGTH = 64 + +/** The borderless field the rest of this family's forms use. */ +@Composable +private fun transparentOutline() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent +) + +@ConformancePreviews +@Composable +private fun AddProjectScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddProjectScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = "b".repeat(64), + relayHint = null, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + 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/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 1e489436..f04cd668 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 @@ -83,6 +83,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState 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.ArtifactDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute @@ -522,6 +523,31 @@ fun ChatRoomDetailScreen( } } + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + AddProjectRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Schema, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.create_project)) + } + } + 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 99c08773..499dc57b 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 @@ -135,6 +135,7 @@ import kotlinx.coroutines.launch import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen 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.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute @@ -147,6 +148,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute 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 +import press.mantra.compose.ui.composable.AddProjectScreen import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.ProjectDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen @@ -1092,6 +1094,38 @@ fun MantraNavHost( }, ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddProjectScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. + navController.navigate( + route = signingRoute + ) { + popUpTo(route) { + inclusive = true + } + } + }, + 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/AddProjectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt new file mode 100644 index 00000000..080b33bf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddProjectRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddProjectRoute( + val chatRoomId: String, + val activeUserPublicKey: String, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt new file mode 100644 index 00000000..2f3e6488 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModel.kt @@ -0,0 +1,206 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.runtime.MutableState +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 com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +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.view.state.AddProjectUIState + +class AddProjectViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddProjectUIState: AddProjectUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addProjectUIState: AddProjectUIState by mutableStateOf(initialAddProjectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddProject() { + logger.d("initiateAddProject: $chatRoomId") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addProjectUIState = if (localChatRoom == null) { + AddProjectUIState.Error("Couldn't find the chat room") + } else { + AddProjectUIState.Loaded( + localChatRoom = localChatRoom, + sourceVersions = sourceVersionsOf(localChatRoom.chatRoom.id), + translations = translationsOf(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Every version in the room's library, walked down from its artifacts. + * + * There is no by-room query for versions -- a version hangs off an artifact, + * which is the same reason the chronicle walks the tree rather than querying + * per kind. A room's library is small and this runs once when the form + * opens. + */ + private suspend fun sourceVersionsOf(chatRoomId: String) = + mantraRepository.getArtifacts(chatRoomId) + .flatMap { mantraRepository.getArtifactVersions(it.id) } + + private suspend fun translationsOf(chatRoomId: String) = + sourceVersionsOf(chatRoomId) + .flatMap { mantraRepository.getTranslationsForArtifactVersion(it.id) } + + /** + * Asks the group to sign a project into being, with whatever it starts with. + * + * The project is not created here and does not exist yet, the way an + * artifact does not: what goes out is a proposal, and the project appears on + * every member's device at once -- authored by the room's own key -- when + * enough members have signed. + * + * One session over the project and its first contents. The memberships + * cannot be built by this screen because each carries the project's id, + * which is a hash over the group's key at the room's derivation path: it + * does not exist until the proposal is made. So the project leads and the + * memberships are derived from it, the same shape an artifact and its first + * version use. + * + * All-or-nothing, which is right. A project is a statement about which work + * belongs together, and half of that statement signed is not a smaller + * version of it. + * + * [creatorPublicKey] is this member and is not asked for. [ownerPublicKeys] + * is asked for and defaults to the room, and must not be empty -- the event + * builder refuses one, and the form keeps the last entry from being removed + * rather than letting it get that far. + */ + fun addProject( + localChatRoom: LocalChatRoom, + nameField: TextFieldState, + descriptionField: TextFieldState, + ownerPublicKeys: Set, + sourceVersionIds: List, + translationIds: List, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + val name = nameField.text.toString() + val description = descriptionField.text.toString() + + if (name.isBlank() || ownerPublicKeys.isEmpty()) { + onFailure.invoke() + return + } + + // Guard against double submits from repeated FAB taps. + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val projectEventTemplate = ProjectEvent.build( + name = name, + description = description, + creatorPublicKey = activeUserPublicKey, + ownerPublicKeys = ownerPublicKeys, + ) + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + lead = projectEventTemplate, + dependents = { project -> + buildList> { + addAll( + ProjectArtifactVersionEvent.membershipsOf( + project = project, + artifactVersionIds = sourceVersionIds, + ) + ) + addAll( + ProjectTranslationArtifactVersionEvent.membershipsOf( + project = project, + translationArtifactVersionIds = translationIds, + ) + ) + } + } + ) + }.onFailure { error -> + logger.e("Failed to propose a project for signing", error) + }.getOrNull() + + if (session != null) { + nameField.clearText() + descriptionField.clearText() + + viewModelScope.launch(Dispatchers.Main) { + onSuccess.invoke(session.id) + } + } else { + viewModelScope.launch(Dispatchers.Main) { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddProjectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddProjectUIState: AddProjectUIState = AddProjectUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddProjectViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddProjectUIState = initialAddProjectUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt new file mode 100644 index 00000000..df06b932 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddProjectUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddProjectUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** Everything in the room's library a project could be about. */ + val sourceVersions: List = emptyList(), + + /** And everything being translated. */ + val translations: List = emptyList(), + + /** + * Whether this device holds a share of the group's key. + * + * A project is proposed, not created, so a device that cannot sign + * cannot start one. Said on the form rather than discovered on the + * button. + */ + val canSign: Boolean = false, + ): AddProjectUIState + + data class Error( + val message: String + ): AddProjectUIState + + data object Loading: AddProjectUIState +} From 7c76359b34537c2a93f0a69e75a1fe263698c7a6 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:48:22 +0200 Subject: [PATCH 17/32] feat(chapters): a chunk is a markdown block, not a blank-line paragraph Phases 7 and 8 of docs/chapter-uploads.md. Measured against markdown-it -- the parser mantra.press translates with -- this app's blank-line split disagreed on ten cases in eleven, and on the text as well as the boundaries. markdown-it's content is a leaf block's inline source with the BLOCK marker removed, so `# Chapter one` is "Chapter one" while inline markup is kept. That is the half that matters to a translator: handed "# Chapter one" they can break the document by mistyping the hash, and handed "Chapter one" they cannot. So splitBlocks adopts it, boundaries and stripped text both, with three deliberate departures. Code blocks yield no chunk -- markdown-it's fence and code_block carry content, so mantra.press asks a translator to translate `code();` and the group to sign it as prose. A table is one chunk of its own source rather than one per cell, because four envelopes to carry one word each is a poor trade and a table's alignment is structure per-cell translation cannot preserve. And a link reference definition yields nothing, which is free: it is markdown-it's own behaviour with the `reference` rule left enabled, where mantra.press disables it -- reaching for a block rule to stop links rendering -- and makes a translatable chunk of a URL. Hand-rolled, not org.jetbrains:markdown, for one reason specific to this function: its output is hashed into ids a quorum signs, so a version bump that moved one boundary would silently re-split every chapter proposed afterwards and nothing would notice. What pins it instead is thirty golden fixtures generated FROM markdown-it by docs/scripts/markdown-block-fixtures.js, which also encodes the three departures so the Kotlin test is plain equality. MarkdownProducer is new and is what makes stripping markers safe: nothing in the app could put a `#` back before. It substitutes by index, which is strictly better than the thing it mirrors -- mantra.press matches on text, so a chapter with two identical paragraphs puts the first one's translation into both. The scanner segments every line rather than only the chunks, which is what makes the byte-for-byte round trip exact by construction rather than by getting every markup rule right. ProposedEvent.summarize stops re-splitting a chapter to count its chunks and takes the count from the batch. That number is a count of the batch dressed as a fact about the text: across a splitter change the figure in the approval gate would disagree with the events being approved, and it re-scanned every open proposal's prose on every emission of the room's list. Phase 2's constant was flagged as provisional pending this, and measured it did not move: prose separated by blank lines splits the same under both rules, so the fixture pinning 24 KiB over forty paragraphs is the shape this does not touch. The narrower claim holds -- a heading- or list-heavy chapter now reaches both caps sooner, visibly, in the form's own counter. 987 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../compose/nostr/nip30303/ChapterProposal.kt | 2 +- .../compose/nostr/nip30303/ChunkEvent.kt | 8 +- .../press/mantra/compose/text/Markdown.kt | 33 +- .../mantra/compose/text/MarkdownBlocks.kt | 337 ++++++++++++++++++ .../mantra/compose/text/MarkdownProducer.kt | 86 +++++ .../mantra/compose/text/ProposedEvent.kt | 22 +- .../compose/ui/composable/AddChapterScreen.kt | 14 +- .../ui/composable/FrostSigningScreen.kt | 11 +- .../ui/view/model/AddChapterViewModel.kt | 8 +- .../ui/view/model/ProposalListViewModel.kt | 11 +- .../compose/ui/view/state/UploadReview.kt | 2 +- .../nostr/nip30303/ChapterChunkSplitTest.kt | 2 +- .../nostr/nip30303/ChapterProposalSizeTest.kt | 10 +- .../mantra/compose/text/ChapterTextTest.kt | 2 +- .../compose/text/MarkdownBlockFixtures.kt | 258 ++++++++++++++ .../mantra/compose/text/MarkdownBlockTest.kt | 36 ++ .../compose/text/MarkdownProducerTest.kt | 155 ++++++++ .../compose/ui/view/state/UploadReviewTest.kt | 5 +- docs/chapter-uploads.md | 42 ++- docs/scripts/markdown-block-fixtures.js | 177 +++++++++ 20 files changed, 1177 insertions(+), 44 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownBlocks.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownProducer.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt create mode 100644 docs/scripts/markdown-block-fixtures.js diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt index 0fb718c5..3961d80c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposal.kt @@ -66,7 +66,7 @@ object ChapterProposal { (chunks.size + 1) * EVENT_OVERHEAD_BYTES fun proposalBytes(originalText: String): Int = - proposalBytes(originalText, Markdown.splitParagraphs(originalText)) + proposalBytes(originalText, Markdown.splitChunks(originalText)) fun fits(originalText: String, chunks: List): Boolean = proposalBytes(originalText, chunks) <= MAX_PROPOSAL_BYTES diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt index 48876fc9..9c363b2f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt @@ -63,13 +63,13 @@ class ChunkEvent( sig = chapter.sig ).originalText() ?: return emptyList() - return Markdown.splitParagraphs(originalText).mapIndexed { index, paragraph -> + return Markdown.splitChunks(originalText).mapIndexed { index, chunk -> build( chapterId = chapter.id, - text = paragraph, + text = chunk, index = index, - wordCount = Markdown.wordCount(paragraph), - characterCount = Markdown.characterCount(paragraph), + wordCount = Markdown.wordCount(chunk), + characterCount = Markdown.characterCount(chunk), createdAt = chapter.createdAt, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt index 5771c2dd..30a5cc86 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/Markdown.kt @@ -9,8 +9,6 @@ package press.mantra.compose.text object Markdown { private val WHITESPACE = Regex("\\s+") - /** A paragraph break is one or more blank lines. */ - private val PARAGRAPH_BREAK = Regex("(?:\\r?\\n){2,}") fun wordCount(text: String): Int { val trimmed = text.trim() @@ -20,11 +18,30 @@ object Markdown { fun characterCount(text: String): Int = text.length /** - * Split markdown into paragraphs (blocks separated by blank lines), - * trimmed and with empties removed. Each paragraph becomes a chunk. + * The blocks a chapter's markdown splits into, one chunk each. + * + * A chunk is a **leaf** block's inline text with its block marker removed: + * the words a translator is given and the words the group signs. Containers + * -- lists, blockquotes -- contribute nothing of their own; a thematic + * break, a code block and a link reference definition contribute nothing at + * all. + * + * This is markdown-it's block token stream, which is what mantra.press + * translates against, with the three departures recorded in + * `docs/chapter-uploads.md`. It replaced a blank-line split, and the two + * disagreed on ten cases in eleven -- on the text as well as the + * boundaries, because markdown-it strips the marker, so `# Chapter one` is + * `"Chapter one"`. Inline markup is kept verbatim: emphasis and links are + * the translator's to carry, where a `#` mistyped into a translation breaks + * the document. + * + * Its output is hashed into chunk ids a quorum signs, so it is pinned by + * `MarkdownBlockFixtures` -- generated from markdown-it itself -- rather + * than by this comment. See [MarkdownBlocks] for the subset it recognises. */ - fun splitParagraphs(text: String): List = - text.split(PARAGRAPH_BREAK) - .map { it.trim() } - .filter { it.isNotEmpty() } + fun splitBlocks(text: String): List = + MarkdownBlocks.segment(text).mapNotNull { it.block } + + /** Just the words of each chunk, which is all an event carries. */ + fun splitChunks(text: String): List = splitBlocks(text).map { it.text } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownBlocks.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownBlocks.kt new file mode 100644 index 00000000..99751009 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownBlocks.kt @@ -0,0 +1,337 @@ +package press.mantra.compose.text + +/** + * What kind of leaf block a chunk came out of. + * + * Carried so [MarkdownProducer] can put the block's marker back when the + * chunk's words have been replaced by a translation. Nothing else reads it. + */ +enum class BlockKind { + Paragraph, + Heading, + SetextHeading, + ListItem, + Quote, + + /** + * A whole table, as its own source. + * + * One of the three deliberate departures from markdown-it, which gives a + * chunk per cell. See `docs/chapter-uploads.md`. + */ + Table, +} + +/** + * One chunk, as the words and the marker that were in front of them. + * + * [text] is what a translator is given and what the group signs: the leaf + * block's inline source with the **block** marker removed -- no `#`, no `> `, + * no `- `, no setext underline -- while inline markup (`*italic*`, a link's + * brackets) is kept verbatim. The first is structure a translation should not + * be able to break by mistyping; the second is the translator's to carry. + * + * [markup] is the marker that was removed, kept so a document can be put back + * together. For an ATX heading it is the run of `#`; for a list item the + * bullet or the ordinal; for a blockquote `>`. It is not stored on the chunk + * event -- it is recoverable from the chapter's own `original_text`, which is + * where [MarkdownProducer] reads it from. + */ +data class MarkdownBlock( + val kind: BlockKind, + val markup: String, + val text: String, + /** The indent the block sat at, so a nested list item goes back where it was. */ + val indent: String = "", +) + +/** + * A run of lines, and the chunk it is -- or nothing, if it is not prose. + * + * A segmentation rather than a split, because two things need it. [block] being + * null is a fenced code block, a thematic break, a link reference definition or + * a blank line: material that carries no chunk and still has to be in the + * document afterwards. + * + * [lines] is the exact source, so a document with no translations applied is + * reassembled byte for byte by joining every segment's lines with `\n`. + */ +data class MarkdownSegment( + val lines: List, + val block: MarkdownBlock?, +) + +/** + * The block structure of a chapter's markdown. + * + * A documented **subset** of CommonMark, and hand-rolled rather than taken from + * a library on purpose: its output is hashed into chunk ids that a quorum + * signs, so a dependency bump that moved one boundary would silently re-split + * every chapter proposed afterwards and nothing in the app would notice. What + * pins it instead is `MarkdownBlockFixtures`, generated from markdown-it by + * `docs/scripts/markdown-block-fixtures.js`. + * + * What it recognises: ATX and setext headings, paragraphs with lazy + * continuation, blockquotes, bullet and ordered list items including one level + * of nesting, fenced and indented code, thematic breaks, tables, and link + * reference definitions. + * + * What it does not, each of which has a fixture saying what happens instead: + * HTML blocks (markdown-it's default is `html: false`, so a `
` is a + * paragraph on both sides), footnotes, and blockquotes containing lists. + */ +internal object MarkdownBlocks { + + private val ATX = Regex("^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$") + private val SETEXT_UNDERLINE = Regex("^ {0,3}(=+|-+)[ \t]*$") + private val THEMATIC_BREAK = Regex("^ {0,3}((\\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$") + private val FENCE = Regex("^ {0,3}(`{3,}|~{3,})(.*)$") + private val BULLET = Regex("^( {0,3})([-*+])([ \t]+)(.*)$") + private val ORDERED = Regex("^( {0,3})(\\d{1,9}[.)])([ \t]+)(.*)$") + private val QUOTE = Regex("^ {0,3}>[ \t]?(.*)$") + private val REFERENCE_DEFINITION = Regex("^ {0,3}\\[[^\\]]+\\]:[ \t]*\\S.*$") + private val INDENTED_CODE = Regex("^ {4,}\\S.*$") + private val TABLE_DELIMITER = Regex("^ {0,3}\\|?[ \t]*:?-{1,}:?[ \t]*(\\|[ \t]*:?-{1,}:?[ \t]*)*\\|?[ \t]*$") + + fun segment(text: String): List { + val lines = text.split("\n") + val segments = mutableListOf() + var i = 0 + + while (i < lines.size) { + val line = lines[i] + + when { + line.isBlank() -> { + segments += MarkdownSegment(listOf(line), null) + i++ + } + + FENCE.matches(line) -> { + val fence = FENCE.find(line)!!.groupValues[1] + val taken = mutableListOf(lines[i]) + i++ + while (i < lines.size) { + taken += lines[i] + val closing = FENCE.find(lines[i])?.groupValues?.get(1) + i++ + if (closing != null && closing.length >= fence.length) break + } + // Departure 1: code is not prose, so no chunk. + segments += MarkdownSegment(taken, null) + } + + THEMATIC_BREAK.matches(line) -> { + segments += MarkdownSegment(listOf(line), null) + i++ + } + + REFERENCE_DEFINITION.matches(line) -> { + // Departure 3, which is markdown-it's own behaviour with the + // `reference` rule enabled: a definition is not prose. + segments += MarkdownSegment(listOf(line), null) + i++ + } + + INDENTED_CODE.matches(line) -> { + val taken = mutableListOf() + while (i < lines.size && (INDENTED_CODE.matches(lines[i]) || lines[i].isBlank())) { + // A blank line only belongs to the code block if more code + // follows it; otherwise it is the separator after it. + if (lines[i].isBlank() && + lines.drop(i + 1).firstOrNull { it.isNotBlank() } + ?.let { !INDENTED_CODE.matches(it) } != false + ) { + break + } + taken += lines[i] + i++ + } + segments += MarkdownSegment(taken, null) + } + + ATX.matches(line) -> { + val match = ATX.find(line)!! + segments += MarkdownSegment( + lines = listOf(line), + block = MarkdownBlock( + kind = BlockKind.Heading, + markup = match.groupValues[1], + text = match.groupValues[2].trim(), + ), + ) + i++ + } + + isTableStart(lines, i) -> { + val taken = mutableListOf() + while (i < lines.size && lines[i].isNotBlank() && lines[i].contains('|')) { + taken += lines[i] + i++ + } + // Departure 2: one chunk of the whole table's source. + segments += MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.Table, + markup = "", + text = taken.joinToString("\n"), + ), + ) + } + + QUOTE.matches(line) -> { + val taken = mutableListOf() + val inner = mutableListOf() + while (i < lines.size && QUOTE.matches(lines[i])) { + taken += lines[i] + inner += QUOTE.find(lines[i])!!.groupValues[1] + i++ + } + // A `>` on its own splits one blockquote into two paragraphs, + // which markdown-it reads as two inline runs and so two chunks. + val paragraphs = inner.splitOnBlank() + if (paragraphs.size <= 1) { + segments += MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.Quote, + markup = ">", + text = inner.joinToString("\n").trim(), + ), + ) + } else { + // Re-segmented per inner paragraph, so each is its own + // chunk and the source still reassembles exactly. + var consumed = 0 + paragraphs.forEachIndexed { index, paragraph -> + val span = paragraph.size + if (index < paragraphs.size - 1) 1 else 0 + segments += MarkdownSegment( + lines = taken.subList(consumed, consumed + span).toList(), + block = MarkdownBlock( + kind = BlockKind.Quote, + markup = ">", + text = paragraph.joinToString("\n").trim(), + ), + ) + consumed += span + } + } + } + + BULLET.matches(line) || ORDERED.matches(line) -> { + val match = (BULLET.find(line) ?: ORDERED.find(line))!! + val indent = match.groupValues[1] + val marker = match.groupValues[2] + val gap = match.groupValues[3] + val taken = mutableListOf(line) + val content = mutableListOf(match.groupValues[4]) + i++ + // Lazy continuation: a plain line under an item belongs to it. + // A new marker, at any indent, is a new item -- which is what + // makes a nested list two chunks rather than one. + while (i < lines.size && isLazyContinuation(lines[i])) { + taken += lines[i] + content += lines[i].trim() + i++ + } + segments += MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.ListItem, + markup = marker + gap, + text = content.joinToString("\n").trim(), + indent = indent, + ), + ) + } + + else -> { + // A paragraph, unless the line after it underlines it. + val taken = mutableListOf(line) + i++ + var setext: String? = null + while (i < lines.size) { + val next = lines[i] + if (SETEXT_UNDERLINE.matches(next) && !THEMATIC_BREAK.matches(next)) { + setext = SETEXT_UNDERLINE.find(next)!!.groupValues[1] + taken += next + i++ + break + } + if (!isLazyContinuation(next)) break + taken += next + i++ + } + + segments += if (setext != null) { + MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.SetextHeading, + markup = setext, + text = taken.dropLast(1).joinToString("\n").trim(), + ), + ) + } else { + MarkdownSegment( + lines = taken, + block = MarkdownBlock( + kind = BlockKind.Paragraph, + markup = "", + text = taken.joinToString("\n").trim(), + ), + ) + } + } + } + } + + return segments + } + + /** + * Whether [line] continues the block above it rather than starting one. + * + * The list is every block start this scanner recognises. A line that starts + * none of them, and is not blank, is prose belonging to whatever came + * before -- which is what markdown-it calls a lazy continuation and is why + * `First line.\nSecond line.` is one chunk and not two. + */ + private fun isLazyContinuation(line: String): Boolean = when { + line.isBlank() -> false + ATX.matches(line) -> false + THEMATIC_BREAK.matches(line) -> false + FENCE.matches(line) -> false + QUOTE.matches(line) -> false + BULLET.matches(line) -> false + ORDERED.matches(line) -> false + REFERENCE_DEFINITION.matches(line) -> false + else -> true + } + + /** A table is a row of cells with a delimiter row under it. */ + private fun isTableStart(lines: List, at: Int): Boolean = + lines[at].contains('|') && + at + 1 < lines.size && + lines[at + 1].contains('-') && + TABLE_DELIMITER.matches(lines[at + 1]) + + /** Splits a blockquote's inner lines into its paragraphs. */ + private fun List.splitOnBlank(): List> { + val out = mutableListOf>() + var current = mutableListOf() + forEach { line -> + if (line.isBlank()) { + if (current.isNotEmpty()) { + out += current + current = mutableListOf() + } + } else { + current += line + } + } + if (current.isNotEmpty()) out += current + return out + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownProducer.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownProducer.kt new file mode 100644 index 00000000..155345b7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/MarkdownProducer.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.text + +/** + * A chapter's markdown with translations in place of its words. + * + * The other half of [Markdown.splitBlocks], and the piece that makes stripping + * block markers safe. A chunk carries `"Chapter one"` rather than + * `"# Chapter one"` so that a translator cannot break the document by mistyping + * the `#` -- which is only defensible if something can put the `#` back. Before + * this, nothing in the app could: there was no equivalent of mantra.press's + * `lib/markdown-producer/index.js` at all. + * + * **Substitution is by index, not by text**, and that is strictly better than + * the thing it mirrors. mantra.press matches with + * `translationChunks.find(tc => tc.text == token.content)`, so a chapter with + * two identical paragraphs puts the first one's translation into both of them. + * A chunk here already carries an `IndexTag`, so position is available and + * exact. + */ +object MarkdownProducer { + + /** + * [originalText] rebuilt with [translations] substituted by chunk index. + * + * A chunk with no translation keeps its own source **verbatim** -- not + * re-rendered from its parsed form -- so a chapter with no translations at + * all comes back byte for byte. That is the property worth having: it means + * every markup-reassembly bug is one failing assertion away rather than a + * difference somebody has to notice in a rendered document. + * + * A translated chunk is rendered from its [MarkdownBlock.kind]: the marker + * goes back in front, and for a blockquote in front of every line, so a + * translation that grew a line is still quoted. + */ + fun produce(originalText: String, translations: Map): String { + var chunkIndex = 0 + + return MarkdownBlocks.segment(originalText).flatMap { segment -> + val block = segment.block ?: return@flatMap segment.lines + + val translation = translations[chunkIndex] + chunkIndex++ + + if (translation.isNullOrEmpty()) segment.lines else render(block, translation) + }.joinToString("\n") + } + + /** How many chunks [originalText] has, which is what a caller indexes against. */ + fun chunkCount(originalText: String): Int = Markdown.splitBlocks(originalText).size + + private fun render(block: MarkdownBlock, translation: String): List = + when (block.kind) { + BlockKind.Paragraph -> translation.lines() + + BlockKind.Heading -> listOf("${block.markup} $translation") + + // The underline has to be as long as the text, or it stops being an + // underline -- a one-character `=` under a translated title is a + // setext heading in CommonMark but reads as a typo. + BlockKind.SetextHeading -> { + // The underline matches the line immediately above it, which is + // the last line of the translation rather than its first: a `=` + // shorter than the text it underlines is still a setext heading + // in CommonMark but reads as a typo. + val lines = translation.lines() + lines + block.markup.first().toString() + .repeat(lines.last().length.coerceAtLeast(1)) + } + + BlockKind.ListItem -> { + val lines = translation.lines() + listOf("${block.indent}${block.markup}${lines.first()}") + + // A continuation line is indented under the marker, so the + // item stays one item rather than becoming a paragraph after + // it. + lines.drop(1).map { "${block.indent}${" ".repeat(block.markup.length)}$it" } + } + + BlockKind.Quote -> translation.lines().map { "> $it" } + + // A table's chunk is its own source, so its translation is too: there + // is no marker to put back, and re-deriving the pipes from a + // translated table would be guessing at its columns. + BlockKind.Table -> translation.lines() + } +} 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 7b11f466..a13bcba6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -31,7 +31,25 @@ object ProposedEvent { */ data class Summary(val label: String, val detail: String) - fun summarize(event: Event): Summary = when (event.kind) { + /** + * [chunkCount] is how many chunks travel with a chapter in the same batch, + * which only a caller holding the batch can know. + * + * It used to be derived here, by re-splitting the chapter's own text. That + * was wrong twice over. It is a *count of the batch* dressed as a fact about + * the text, so across a splitter change the number a member reads in the + * approval gate disagrees with the chunks they are actually approving -- the + * batch was built by the proposer's splitter and counted by the reader's. + * And it re-split the whole markdown of every chapter on every emission of + * the room's proposal list, which at twenty open proposals is a few hundred + * kilobytes of prose re-scanned to produce a number already sitting in the + * items beside it. + * + * Null where the caller has no batch -- a transcript line, a signed event on + * its own -- and the summary then says nothing about chunks rather than + * guessing. + */ + fun summarize(event: Event, chunkCount: Int? = null): Summary = when (event.kind) { DialectEvent.KIND -> Summary( label = "New dialect", detail = DialectEvent( @@ -74,8 +92,6 @@ object ProposedEvent { // is a whole chapter, so its size stands in for it here. The chunk // count says how many of the batch's other items came out of this // text, which is the rest of what is being signed. - val chunkCount = chapter.originalText()?.let { Markdown.splitParagraphs(it).size } - listOfNotNull( chapter.name(), chapter.wordCount()?.let { "$it words" }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index 98ee337a..4ff53dc8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -168,8 +168,8 @@ fun AddChapterScreen( val originalText = originalTextFieldState.text.toString() val wordCount = Markdown.wordCount(originalText) val characterCount = Markdown.characterCount(originalText) - val chunks = Markdown.splitParagraphs(originalText) - val paragraphCount = chunks.size + val chunks = Markdown.splitChunks(originalText) + val chunkCount = chunks.size // A chapter hangs off a version, and the group has to be able to // sign; without both there is nothing this screen can propose. @@ -179,7 +179,7 @@ fun AddChapterScreen( // and a session signs a bounded number of events. Past that the // chapter has to be split in two, which is worth saying while there // is still a cursor in the text rather than after a failed propose. - val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER + val tooManyChunks = chunkCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER // The other cap, and the one that had been enforced nowhere: a // proposal carries the chapter whole and every chunk cut from @@ -191,7 +191,7 @@ fun AddChapterScreen( val canProposeChapter = artifactVersion != null && addChapterUIState.canSign && - paragraphCount > 0 && + chunkCount > 0 && !tooManyChunks && !tooLargeToPropose @@ -268,7 +268,7 @@ fun AddChapterScreen( Text( text = stringResource( Res.string.d_chunks_is_more_than_the_group_can_sign, - paragraphCount, + chunkCount, AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER, ), style = MaterialTheme.typography.bodySmall, @@ -278,7 +278,7 @@ fun AddChapterScreen( Text( text = stringResource( Res.string.this_chapter_is_too_long_to_propose, - paragraphCount, + chunkCount, stringResource(Res.string.s_kib, kibibytes(proposalBytes)), stringResource( Res.string.s_kib, @@ -337,7 +337,7 @@ fun AddChapterScreen( kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES), ), ), - paragraphCount, + chunkCount, AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER, ), style = MaterialTheme.typography.labelMedium, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index fbecea82..ec1fb4b9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -48,6 +48,7 @@ import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.MantraTheme @@ -358,10 +359,14 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { ) } + // The chunk count a chapter reports is a count of this batch, so it + // comes from here rather than from re-reading the chapter's text. + val chunkCount = events.count { it.kind == ChunkEvent.KIND } + events.forEachIndexed { index, event -> if (index > 0) HorizontalDivider() - OneThingBeingSigned(event) + OneThingBeingSigned(event, chunkCount) } Text( @@ -376,8 +381,8 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { /** One event of the batch, described as the thing it is. */ @Composable -private fun OneThingBeingSigned(event: Event) { - val summary = ProposedEvent.summarize(event) +private fun OneThingBeingSigned(event: Event, chunkCount: Int) { + val summary = ProposedEvent.summarize(event, chunkCount) Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) { Text(text = summary.label, style = MaterialTheme.typography.labelMedium) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index bea91925..8bde3330 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -102,7 +102,7 @@ class AddChapterViewModel( ) { val name = nameField.text.toString() val originalText = originalTextField.text.toString() - val paragraphs = Markdown.splitParagraphs(originalText) + val chunks = Markdown.splitChunks(originalText) // Both caps are the group's, not this screen's, and proposing past // either throws rather than failing softly. The form says so as it is @@ -113,9 +113,9 @@ class AddChapterViewModel( // weighs. A chapter of sixty-three one-word paragraphs passes the second // and fails the first; one of three very long ones does the reverse. if (name.isBlank() || - paragraphs.isEmpty() || - paragraphs.size > MAX_CHUNKS_PER_CHAPTER || - !ChapterProposal.fits(originalText, paragraphs) + chunks.isEmpty() || + chunks.size > MAX_CHUNKS_PER_CHAPTER || + !ChapterProposal.fits(originalText, chunks) ) { onFailure.invoke() return diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt index 938d3d83..e6e40587 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt @@ -18,6 +18,7 @@ import press.mantra.compose.extensions.shortened import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.view.state.ProposalListUIState @@ -86,7 +87,15 @@ class ProposalListViewModel( else -> local.proposer?.humanReadableNameOrPubkey() ?: local.session.coordinatorPublicKey.shortened() }, - signs = events.map { ProposedEvent.summarize(it) }, + // Counted from the batch rather than derived from the chapter's + // text -- the number a member reads has to be the number of events + // they are approving. See ProposedEvent.summarize. + signs = events.map { event -> + ProposedEvent.summarize( + event = event, + chunkCount = events.count { it.kind == ChunkEvent.KIND }, + ) + }, unreadable = items.size - events.size, awaitsYou = FrostSigningManager.isAwaitingApproval(local.session, items) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt index cf817af2..931d5b3d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UploadReview.kt @@ -36,7 +36,7 @@ object UploadReview { private fun row(file: PickedFile, ordinal: Int): PickedChapter { val decoded = ChapterText.decode(file.bytes) val markdown = (decoded as? ChapterText.Result.Text)?.markdown.orEmpty() - val chunks = Markdown.splitParagraphs(markdown) + val chunks = Markdown.splitChunks(markdown) return PickedChapter( // Neither the file's name nor its text is unique -- two directories diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt index e897939e..303a03c4 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt @@ -168,7 +168,7 @@ class ChapterChunkSplitTest { val text = "One.\n\nTwo.\n\n\nThree.\n\n \n\nFour." assertEquals( - Markdown.splitParagraphs(text).size, + Markdown.splitChunks(text).size, ChunkEvent.splitOf(unsignedChapter(originalText = text)).size ) } 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 dc9c477e..ec1080fe 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 @@ -80,7 +80,7 @@ class ChapterProposalSizeTest { for (paragraphs in shapes) { for (kib in sizes) { val text = markdownOf(kib * 1024, paragraphs) - val chunks = Markdown.splitParagraphs(text) + val chunks = Markdown.splitChunks(text) val estimated = ChapterProposal.proposalBytes(text, chunks) val actual = encodedBytes(text) @@ -98,7 +98,7 @@ class ChapterProposalSizeTest { // Not merely conservative -- useful. An estimate twice the truth would // pass the test above and refuse half of every legal chapter. val text = markdownOf(16 * 1024, 40) - val chunks = Markdown.splitParagraphs(text) + val chunks = Markdown.splitChunks(text) val estimated = ChapterProposal.proposalBytes(text, chunks) val actual = encodedBytes(text) @@ -117,7 +117,7 @@ class ChapterProposalSizeTest { val chunkCount = AddChapterMaxChunks val allowance = ChapterProposal.maxOriginalTextBytes(chunkCount) val text = markdownOf(allowance, chunkCount) - val chunks = Markdown.splitParagraphs(text) + val chunks = Markdown.splitChunks(text) assertEquals(chunkCount, chunks.size) assertTrue(ChapterProposal.fits(text, chunks)) @@ -134,7 +134,7 @@ class ChapterProposalSizeTest { // paragraphs is a long chapter of a real book, not a pathological one -- // and measured, the fit runs out at about 24 KiB for that shape. val text = markdownOf(28 * 1024, 40) - val chunks = Markdown.splitParagraphs(text) + val chunks = Markdown.splitChunks(text) assertTrue( encodedBytes(text) > FrostSigningManager.MAX_PROPOSAL_BYTES, @@ -162,7 +162,7 @@ class ChapterProposalSizeTest { // The other half of the claim: this is a limit on very long chapters, // not a tax on normal ones. Twelve paragraphs of a few hundred words. val text = markdownOf(8 * 1024, 12) - val chunks = Markdown.splitParagraphs(text) + val chunks = Markdown.splitChunks(text) assertTrue(ChapterProposal.fits(text, chunks)) assertTrue(encodedBytes(text) < FrostSigningManager.MAX_PROPOSAL_BYTES / 2) 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 712ee976..aa99eaed 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt @@ -78,7 +78,7 @@ class ChapterTextTest { fun `splitting is unaffected by which newlines arrived`() { val crlf = decoded("One.\r\n\r\nTwo.\r\n\r\nThree.".encodeToByteArray()) - assertEquals(listOf("One.", "Two.", "Three."), Markdown.splitParagraphs(crlf)) + assertEquals(listOf("One.", "Two.", "Three."), Markdown.splitChunks(crlf)) } @Test diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt new file mode 100644 index 00000000..6b9f38c7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt @@ -0,0 +1,258 @@ +package press.mantra.compose.text + +// GENERATED by docs/scripts/markdown-block-fixtures.js -- do not edit by hand. +// +// The expected output of [Markdown.splitBlocks] for 30 inputs, taken from +// markdown-it with the three departures recorded in docs/chapter-uploads.md +// applied by the generator. Regenerate with: +// +// node docs/scripts/markdown-block-fixtures.js \ +// --markdown-it /node_modules/markdown-it +// +// A change here is a change to what a chunk is, and so to the ids a quorum +// signs. It should arrive with a reason. + +data class Fixture( + val name: String, + val markdown: String, + val chunks: List, +) + +val MARKDOWN_BLOCK_FIXTURES: List = listOf( + Fixture( + name = "one paragraph", + markdown = "A single paragraph.\n", + chunks = listOf( + "A single paragraph.", + ), + ), + Fixture( + name = "two paragraphs", + markdown = "First paragraph.\n\nSecond paragraph.\n", + chunks = listOf( + "First paragraph.", + "Second paragraph.", + ), + ), + Fixture( + name = "lazy continuation", + markdown = "First line.\nSecond line of the same paragraph.\n", + chunks = listOf( + "First line.\nSecond line of the same paragraph.", + ), + ), + Fixture( + name = "atx heading apart", + markdown = "# Chapter one\n\nThe first paragraph.\n", + chunks = listOf( + "Chapter one", + "The first paragraph.", + ), + ), + Fixture( + name = "atx heading glued", + markdown = "# Chapter one\nThe first paragraph.\n", + chunks = listOf( + "Chapter one", + "The first paragraph.", + ), + ), + Fixture( + name = "deep atx heading", + markdown = "#### A fourth-level heading\n\nBody.\n", + chunks = listOf( + "A fourth-level heading", + "Body.", + ), + ), + Fixture( + name = "converter shape", + markdown = "# Chapter one\nFirst paragraph here.\n\nSecond paragraph here.\n", + chunks = listOf( + "Chapter one", + "First paragraph here.", + "Second paragraph here.", + ), + ), + Fixture( + name = "setext h1", + markdown = "Title\n=====\n\nBody.\n", + chunks = listOf( + "Title", + "Body.", + ), + ), + Fixture( + name = "setext h2", + markdown = "Subtitle\n--------\n\nBody.\n", + chunks = listOf( + "Subtitle", + "Body.", + ), + ), + Fixture( + name = "tight bullet list", + markdown = "Shopping:\n\n- milk\n- bread\n- eggs\n", + chunks = listOf( + "Shopping:", + "milk", + "bread", + "eggs", + ), + ), + Fixture( + name = "ordered list", + markdown = "1. first\n2. second\n", + chunks = listOf( + "first", + "second", + ), + ), + Fixture( + name = "nested list", + markdown = "- outer\n - inner\n", + chunks = listOf( + "outer", + "inner", + ), + ), + Fixture( + name = "blockquote two lines", + markdown = "> line one\n> line two\n", + chunks = listOf( + "line one\nline two", + ), + ), + Fixture( + name = "two blockquotes", + markdown = "> first\n\n> second\n", + chunks = listOf( + "first", + "second", + ), + ), + Fixture( + name = "thematic break", + markdown = "One.\n\n---\n\nTwo.\n", + chunks = listOf( + "One.", + "Two.", + ), + ), + Fixture( + name = "fenced code", + markdown = "Text.\n\n```\ncode();\n```\n\nMore.\n", + chunks = listOf( + "Text.", + "More.", + ), + ), + Fixture( + name = "fenced code with info", + markdown = "Text.\n\n```kotlin\nval x = 1\n```\n", + chunks = listOf( + "Text.", + ), + ), + Fixture( + name = "indented code", + markdown = "Text.\n\n indented code\n\nMore.\n", + chunks = listOf( + "Text.", + "More.", + ), + ), + Fixture( + name = "table", + markdown = "| a | b |\n|---|---|\n| 1 | 2 |\n", + chunks = listOf( + "| a | b |\n|---|---|\n| 1 | 2 |", + ), + ), + Fixture( + name = "table with prose", + markdown = "Before.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter.\n", + chunks = listOf( + "Before.", + "| a | b |\n|---|---|\n| 1 | 2 |", + "After.", + ), + ), + Fixture( + name = "link reference definition", + markdown = "See [it][ref].\n\n[ref]: https://example.com\n", + chunks = listOf( + "See [it][ref].", + ), + ), + Fixture( + name = "inline markup survives", + markdown = "A *word* and a [link](https://example.com) and `code`.\n", + chunks = listOf( + "A *word* and a [link](https://example.com) and `code`.", + ), + ), + Fixture( + name = "trailing hard break", + markdown = "Line one \nLine two\n", + chunks = listOf( + "Line one \nLine two", + ), + ), + Fixture( + name = "no trailing newline", + markdown = "No newline at the end.", + chunks = listOf( + "No newline at the end.", + ), + ), + Fixture( + name = "leading blank lines", + markdown = "\n\nA paragraph after blank lines.\n", + chunks = listOf( + "A paragraph after blank lines.", + ), + ), + Fixture( + name = "crlf already normalised", + markdown = "One.\n\nTwo.\n", + chunks = listOf( + "One.", + "Two.", + ), + ), + Fixture( + name = "html block falls back to a paragraph", + markdown = "
html block
\n", + chunks = listOf( + "
html block
", + ), + ), + Fixture( + name = "heading then list then quote", + markdown = "# Title\n\n- one\n- two\n\n> quoted\n\nEnd.\n", + chunks = listOf( + "Title", + "one", + "two", + "quoted", + "End.", + ), + ), + Fixture( + name = "multi paragraph blockquote", + markdown = "> first para\n>\n> second para\n", + chunks = listOf( + "first para", + "second para", + ), + ), + Fixture( + name = "unicode", + markdown = "Ke a leboga.\n\nሰላም ለዓለም።\n", + chunks = listOf( + "Ke a leboga.", + "ሰላም ለዓለም።", + ), + ), +) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt new file mode 100644 index 00000000..d272bad3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockTest.kt @@ -0,0 +1,36 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [Markdown.splitBlocks] against markdown-it, which is the parser mantra.press + * translates with. + * + * The fixtures are generated rather than written: `splitBlocks` is hand-rolled, + * its output is hashed into chunk ids a quorum signs, and "it looks right" is + * not a standard that survives a tag being added to an event. See + * `docs/scripts/markdown-block-fixtures.js`, which also encodes the three + * deliberate departures so that this file is a plain equality check. + */ +class MarkdownBlockTest { + + @Test + fun `every fixture splits as markdown-it does`() { + val wrong = MARKDOWN_BLOCK_FIXTURES.mapNotNull { fixture -> + val actual = Markdown.splitChunks(fixture.markdown) + if (actual == fixture.chunks) { + null + } else { + "${fixture.name}:\n expected ${fixture.chunks}\n actual $actual" + } + } + + assertEquals(emptyList(), wrong, "\n" + wrong.joinToString("\n")) + } + + @Test + 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 new file mode 100644 index 00000000..455ed8a1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownProducerTest.kt @@ -0,0 +1,155 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Putting a chapter back together with translations in it. + * + * The first test is the one that matters and is the reason stripping block + * markers is safe at all: a chapter with no translations comes back byte for + * byte. Every markup-reassembly bug is then one failing assertion rather than a + * difference somebody has to notice in a rendered document. + */ +class MarkdownProducerTest { + + @Test + fun `every fixture round-trips byte for byte with nothing translated`() { + val wrong = MARKDOWN_BLOCK_FIXTURES.mapNotNull { fixture -> + val out = MarkdownProducer.produce(fixture.markdown, emptyMap()) + if (out == fixture.markdown) null else "${fixture.name}: ${out.encodeToByteArray().size} vs ${fixture.markdown.encodeToByteArray().size}\n got ${out.replace("\n", "\\n")}\n was ${fixture.markdown.replace("\n", "\\n")}" + } + + assertEquals(emptyList(), wrong, "\n" + wrong.joinToString("\n")) + } + + @Test + fun `a translated paragraph replaces its own words`() { + val source = "First paragraph.\n\nSecond paragraph.\n" + + assertEquals( + "First paragraph.\n\nDie tweede paragraaf.\n", + MarkdownProducer.produce(source, mapOf(1 to "Die tweede paragraaf.")), + ) + } + + @Test + fun `a heading keeps its hashes`() { + val source = "# Chapter one\n\nBody.\n" + + assertEquals( + "# Kapitel een\n\nBody.\n", + MarkdownProducer.produce(source, mapOf(0 to "Kapitel een")), + ) + } + + @Test + fun `a heading glued to its paragraph stays glued`() { + // The converter's shape. Under the old blank-line split these were one + // chunk, so the heading could not be translated apart at all. + val source = "# Chapter one\nFirst paragraph.\n" + + assertEquals( + "# Kapitel een\nDie eerste paragraaf.\n", + MarkdownProducer.produce(source, mapOf(0 to "Kapitel een", 1 to "Die eerste paragraaf.")), + ) + } + + @Test + fun `a list item keeps its marker and its indent`() { + val source = "- outer\n - inner\n" + + assertEquals( + "- buitenste\n - binneste\n", + MarkdownProducer.produce(source, mapOf(0 to "buitenste", 1 to "binneste")), + ) + } + + @Test + fun `an ordered list keeps its numbers`() { + val source = "1. first\n2. second\n" + + assertEquals( + "1. eerste\n2. tweede\n", + MarkdownProducer.produce(source, mapOf(0 to "eerste", 1 to "tweede")), + ) + } + + @Test + 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 + // translation that grew a line is still quoted. + assertEquals( + "> een\n> twee\n> drie\n", + MarkdownProducer.produce(source, mapOf(0 to "een\ntwee\ndrie")), + ) + } + + @Test + fun `a setext heading is underlined to the length of its translation`() { + val source = "Title\n=====\n\nBody.\n" + + assertEquals( + "Titel hier\n==========\n\nBody.\n", + MarkdownProducer.produce(source, mapOf(0 to "Titel hier")), + ) + } + + @Test + fun `code is passed through untranslated and uncounted`() { + val source = "Text.\n\n```\ncode();\n```\n\nMore.\n" + + // The fence carries no chunk, so index 1 is the paragraph after it -- + // which is the whole point of code not being a chunk. + assertEquals( + "Teks.\n\n```\ncode();\n```\n\nMeer.\n", + MarkdownProducer.produce(source, mapOf(0 to "Teks.", 1 to "Meer.")), + ) + } + + @Test + fun `a thematic break and a reference definition are passed through`() { + val source = "One.\n\n---\n\nSee [it][ref].\n\n[ref]: https://example.com\n" + + assertEquals( + "Een.\n\n---\n\nSien [dit][ref].\n\n[ref]: https://example.com\n", + MarkdownProducer.produce(source, mapOf(0 to "Een.", 1 to "Sien [dit][ref].")), + ) + } + + @Test + fun `two identical paragraphs translated differently keep their own words`() { + // The bug in the thing this mirrors. mantra.press matches with + // `translationChunks.find(tc => tc.text == token.content)`, so the first + // translation goes into both. Chunks here carry an index, so position is + // available and exact. + val source = "Same words.\n\nSame words.\n" + + assertEquals( + "Eerste.\n\nTweede.\n", + MarkdownProducer.produce(source, mapOf(0 to "Eerste.", 1 to "Tweede.")), + ) + } + + @Test + fun `an empty translation leaves the original alone`() { + // A translation chunk exists before it has words in it, so this is the + // ordinary case for most of a chapter's life, not an edge one. + val source = "First.\n\nSecond.\n" + + assertEquals(source, MarkdownProducer.produce(source, mapOf(0 to "", 1 to ""))) + } + + @Test + fun `the chunk count agrees with the splitter`() { + MARKDOWN_BLOCK_FIXTURES.forEach { fixture -> + assertEquals( + fixture.chunks.size, + MarkdownProducer.chunkCount(fixture.markdown), + fixture.name, + ) + } + } +} 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 479ed1f3..d910d905 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 @@ -35,7 +35,10 @@ class UploadReviewTest { assertEquals(1, rows.size) assertNull(rows.single().problem) assertEquals("The Wreck", rows.single().name) - assertEquals(listOf("# The wreck", "It began badly."), rows.single().chunks) + // The heading is its own chunk and carries no `#`: a chunk is a leaf + // block's words with the block marker taken off, so a translator cannot + // break the document by mistyping it. See Markdown.splitBlocks. + assertEquals(listOf("The wreck", "It began badly."), rows.single().chunks) } @Test diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index d5695041..ba1d81cf 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -890,6 +890,37 @@ Either take this phase ahead of Phase 2, or treat Phase 2's constant as provisional and re-run its encoded-proposal measurement here. The second is cheaper and the measurement is the same test either way. +**Measured, and it did not move.** `ChapterProposalSizeTest`'s pinned boundary — +24 KiB of markdown over forty paragraphs — is identical before and after, for a +plain reason the warning above missed: prose separated by blank lines splits the +same way under both rules, so the fixture that pins the number is exactly the +shape the change does not touch. What *is* true is the narrower claim: a +list-heavy or heading-heavy chapter now reaches both caps sooner than the same +bytes of plain prose would, which is a real effect on real chapters and shows up +in the form's own counter rather than in a constant. + +### What else came out of it + +Three things the plan had not separated out: + +- **`splitBlocks` returns blocks, not strings.** The marker has to be kept + *somewhere* for the producer, and the chapter's `original_text` is the wrong + place to look it up from — matching text back to its marker is the + string-equality bug this phase fixes elsewhere. So `MarkdownBlock(kind, + markup, text, indent)`, and `splitChunks` for the callers that only want the + words. +- **The scanner segments the whole text, not just the chunks.** + `MarkdownSegment` covers every line, with `block = null` for a fence, a + thematic break, a reference definition or a blank line. That is what makes the + byte-for-byte round trip possible at all: an untranslated chunk is reassembled + from its own source lines rather than re-rendered from its parsed form, so the + round trip is exact by construction rather than by getting every markup rule + right. +- **A blockquote containing a blank `>` line is two chunks, not one.** + markdown-it reads it as two inline runs, so the scanner re-segments it per + inner paragraph — and the fixture for it is the one that would have caught + this being missed. + ### Tests `commonTest`, all of it pure: @@ -900,10 +931,13 @@ cheaper and the measurement is the same test either way. block yield no chunk, a table yields one, a reference definition yields none. - inline markup survives: `*italic*` and `[text](url)` are in the chunk's text. - `MarkdownProducerTest`: round-trip with no translations reproduces the input - byte for byte (the assertion that catches every markup-reassembly bug at - once), and a chapter with two identical paragraphs translated differently - comes back with both translations in the right places — the case mantra.press - gets wrong. + byte for byte **over all thirty fixtures** (the assertion that catches every + markup-reassembly bug at once), and a chapter with two identical paragraphs + translated differently comes back with both translations in the right places — + the case mantra.press gets wrong. Plus the markers going back on: hashes, list + markers and indents, a blockquote re-quoted line by line so a translation that + grew a line is still quoted, and a setext underline re-cut to the length of + the translated title. - `ChapterChunkSplitTest` keeps its existing round-trip and id-stability assertions, now with the converter's output shape as a fixture. diff --git a/docs/scripts/markdown-block-fixtures.js b/docs/scripts/markdown-block-fixtures.js new file mode 100644 index 00000000..ffb008e6 --- /dev/null +++ b/docs/scripts/markdown-block-fixtures.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// +// Generates the golden fixtures for `Markdown.splitBlocks`, from markdown-it. +// +// `Markdown.splitBlocks` is hand-rolled, and its output is hashed into chunk +// event ids that a quorum signs -- so it is pinned by measurement against the +// reference implementation rather than by anybody's reading of CommonMark. This +// script is that measurement. It runs markdown-it over the corpus below and +// writes the expected chunks as a Kotlin source file in commonTest. +// +// node docs/scripts/markdown-block-fixtures.js \ +// --markdown-it \ +// [--out composeApp/src/commonTest/kotlin/.../MarkdownBlockFixtures.kt] +// +// mantra.press has one: its node_modules/markdown-it. Pass that path. +// +// THE THREE DEPARTURES are applied here, in the oracle, so the Kotlin test is a +// plain equality check and the departures are visible in one place: +// +// 1. A fenced or indented code block yields no chunk. markdown-it's `fence` +// and `code_block` tokens carry content, so mantra.press turns `code();` +// into a chunk -- with a trailing newline in its text -- and asks a +// translator to translate it and the group to sign it as prose. +// 2. A table is one chunk of its own source, not one per cell. Four chunks +// for a 2x2 table is markdown-it's honest reading, but each costs an event +// envelope to carry one word and a table's alignment is structure that +// per-cell translation cannot preserve. +// 3. A link reference definition yields no chunk. This one is free: it is +// markdown-it's own behaviour with the `reference` block rule left +// enabled. mantra.press disables it -- reaching for the wrong rule name to +// stop links rendering -- which drops `[ref]: https://...` through to +// `paragraph` and makes a translatable chunk of a URL. +// +// `html: false` is markdown-it's default and is kept: an HTML block comes back +// as a paragraph, which is what the Kotlin subset does too. + +const fs = require('fs') +const path = require('path') + +function arg(name, fallback) { + const i = process.argv.indexOf(name) + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback +} + +const mdPath = arg('--markdown-it', null) +if (!mdPath) { + console.error('--markdown-it is required') + process.exit(2) +} +const outPath = arg( + '--out', + 'composeApp/src/commonTest/kotlin/press/mantra/compose/text/MarkdownBlockFixtures.kt' +) + +const md = require(path.resolve(mdPath))() + +/** + * The corpus. Each entry is a shape that either appears in real chapters or has + * already caught something. The converter shapes come from mantra.press's own + * scripts/convert-btc-tex-to-markdown.js, which is what the files being + * uploaded are made by. + */ +const CORPUS = { + 'one paragraph': 'A single paragraph.\n', + 'two paragraphs': 'First paragraph.\n\nSecond paragraph.\n', + 'lazy continuation': 'First line.\nSecond line of the same paragraph.\n', + 'atx heading apart': '# Chapter one\n\nThe first paragraph.\n', + 'atx heading glued': '# Chapter one\nThe first paragraph.\n', + 'deep atx heading': '#### A fourth-level heading\n\nBody.\n', + 'converter shape': '# Chapter one\nFirst paragraph here.\n\nSecond paragraph here.\n', + 'setext h1': 'Title\n=====\n\nBody.\n', + 'setext h2': 'Subtitle\n--------\n\nBody.\n', + 'tight bullet list': 'Shopping:\n\n- milk\n- bread\n- eggs\n', + 'ordered list': '1. first\n2. second\n', + 'nested list': '- outer\n - inner\n', + 'blockquote two lines': '> line one\n> line two\n', + 'two blockquotes': '> first\n\n> second\n', + 'thematic break': 'One.\n\n---\n\nTwo.\n', + 'fenced code': 'Text.\n\n```\ncode();\n```\n\nMore.\n', + 'fenced code with info': 'Text.\n\n```kotlin\nval x = 1\n```\n', + 'indented code': 'Text.\n\n indented code\n\nMore.\n', + 'table': '| a | b |\n|---|---|\n| 1 | 2 |\n', + 'table with prose': 'Before.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter.\n', + 'link reference definition': 'See [it][ref].\n\n[ref]: https://example.com\n', + 'inline markup survives': 'A *word* and a [link](https://example.com) and `code`.\n', + 'trailing hard break': 'Line one \nLine two\n', + 'no trailing newline': 'No newline at the end.', + 'leading blank lines': '\n\nA paragraph after blank lines.\n', + 'crlf already normalised': 'One.\n\nTwo.\n', + 'html block falls back to a paragraph': '
html block
\n', + 'heading then list then quote': + '# Title\n\n- one\n- two\n\n> quoted\n\nEnd.\n', + 'multi paragraph blockquote': '> first para\n>\n> second para\n', + 'unicode': 'Ke a leboga.\n\nሰላም ለዓለም።\n', +} + +/** The expected chunks for one input, with the three departures applied. */ +function expected(source) { + const lines = source.split('\n') + const tokens = md.parse(source, {}) + const out = [] + + let tableDepth = 0 + + for (const token of tokens) { + if (token.type === 'table_open') { + // Departure 2: one chunk of the table's own source. + const [start, end] = token.map + out.push(lines.slice(start, end).join('\n')) + tableDepth++ + continue + } + if (token.type === 'table_close') { + tableDepth-- + continue + } + if (tableDepth > 0) continue + + // Departure 1: code carries content and is not prose. + if (token.type === 'fence' || token.type === 'code_block') continue + + // Departure 3 needs no code: with the `reference` rule enabled, a link + // reference definition produces no token at all. + + if (token.type === 'inline' && token.content) out.push(token.content) + } + + return out +} + +function kotlinString(value) { + return '"' + value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + '"' +} + +const entries = Object.entries(CORPUS).map(([name, source]) => { + const chunks = expected(source) + return ` Fixture( + name = ${kotlinString(name)}, + markdown = ${kotlinString(source)}, + chunks = listOf(${chunks.length === 0 ? '' : '\n' + chunks.map(c => ' ' + kotlinString(c)).join(',\n') + ',\n '}), + ),` +}).join('\n') + +const file = `package press.mantra.compose.text + +// GENERATED by docs/scripts/markdown-block-fixtures.js -- do not edit by hand. +// +// The expected output of [Markdown.splitBlocks] for ${Object.keys(CORPUS).length} inputs, taken from +// markdown-it with the three departures recorded in docs/chapter-uploads.md +// applied by the generator. Regenerate with: +// +// node docs/scripts/markdown-block-fixtures.js \\ +// --markdown-it /node_modules/markdown-it +// +// A change here is a change to what a chunk is, and so to the ids a quorum +// signs. It should arrive with a reason. + +data class Fixture( + val name: String, + val markdown: String, + val chunks: List, +) + +val MARKDOWN_BLOCK_FIXTURES: List = listOf( +${entries} +) +` + +fs.mkdirSync(path.dirname(outPath), { recursive: true }) +fs.writeFileSync(outPath, file) +console.log(`wrote ${Object.keys(CORPUS).length} fixtures to ${outPath}`) From 3668b3957511e8f94f48337796ab401853385113 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:49:57 +0200 Subject: [PATCH 18/32] feat(projects): add to an existing project, and names instead of hashes Phase 7c of docs/project-feature.md, and the last of the phases this plan builds. 8 and 9 -- removal, and projects that span rooms -- stay deferred. The resolver comes first because the screen depends on it. A membership event carries two 64-character ids and nothing else, so proposing one on its own means every other member is shown "sign a4f1be55d0c3" and asked to agree. That is not a question anybody can answer, and the plan said not to ship this screen without the fix. So `FrostSigningViewModel` resolves what the proposed events refer to -- the profile behind a creator or an owner, the artifact and label behind a version, the name behind a project -- and hands `summarize` a lookup. Synchronous, because two of the four call sites are composables and cannot await a database; pre-resolved in the view model for the same reason. Only what this device already holds: a key with no profile and an id with no row are left out and fall back to a short hash, which is truthful rather than a gap. A project's own id still resolves to nothing while it is being proposed, since nothing has applied it yet. That is why a membership's summary says the version first. `AddToProjectScreen` is the simpler half of founding a project: the project exists, so its id is known and there is no lead and no batch -- one event, one session. Candidates are filtered to what the project does not already hold rather than shown and refused, because a duplicate membership is a second event with its own id and nothing downstream would stop it; it would just cost a quorum to say what the group has already said. One at a time, by radio button rather than checkboxes. A quorum per thing added is the grain this app signs at, and a multi-select here would quietly be a batch whose items a member has to weigh separately anyway. M3 budgets met. 1403 tests, no failures. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 5 + .../ui/composable/AddToProjectScreen.kt | 302 ++++++++++++++++++ .../ui/composable/FrostSigningScreen.kt | 35 +- .../ui/composable/ProjectDetailScreen.kt | 48 +++ .../ui/composable/navigation/MantraNavHost.kt | 37 +++ .../navigation/routes/AddToProjectRoute.kt | 20 ++ .../ui/view/model/AddToProjectViewModel.kt | 190 +++++++++++ .../ui/view/model/FrostSigningViewModel.kt | 86 ++++- .../ui/view/state/AddToProjectUIState.kt | 32 ++ .../ui/view/state/FrostSigningUIState.kt | 16 + 10 files changed, 763 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 552fe3a7..1d0e777a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,8 @@ --> Add an owner Add project + Add source version + Add translation An owner is a 64-character public key. Mantra Add a dialect the group can translate into @@ -64,6 +66,8 @@ Created by What this project is e.g. The four gospels into Sesotho + Every translation in this group is already in this project. + Everything in this group's library is already in this project. Name of project No source versions in this project yet. No translations in this project yet. @@ -71,6 +75,7 @@ Owner public key Owners Project detail + Propose adding Propose project Remove owner Source versions diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt new file mode 100644 index 00000000..0d760299 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddToProjectScreen.kt @@ -0,0 +1,302 @@ +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.height +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.Add +import androidx.compose.material.icons.filled.Article +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +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.RadioButton +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.add_source_version_to_project +import mantra.composeapp.generated.resources.add_translation_to_project +import mantra.composeapp.generated.resources.every_translation_is_already_in_this_project +import mantra.composeapp.generated.resources.everything_in_the_library_is_already_in_this_project +import mantra.composeapp.generated.resources.project_detail +import mantra.composeapp.generated.resources.propose_adding_to_the_project +import org.jetbrains.compose.resources.stringResource +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.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +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 +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AddToProjectViewModel +import press.mantra.compose.ui.view.state.AddToProjectUIState + +/** + * One more piece of work into a project that already exists. + * + * Unlike founding a project, this proposes a single membership -- so the whole + * of what the group is asked to agree to is two ids. That is why + * `FrostSigningViewModel` resolves names for them: without it the other members + * are shown a truncated hash and asked to sign it, which is not a question + * anybody can answer. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddToProjectScreen( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + translations: Boolean, + relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState = AddToProjectUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val addToProjectViewModel: AddToProjectViewModel = viewModel( + factory = AddToProjectViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + translations = translations, + relayHint = relayHint, + initialAddToProjectUIState = initialAddToProjectUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + ) + + ScreenStateTransition(addToProjectViewModel.addToProjectUIState) { uiState -> + when (val addToProjectUIState = uiState) { + is AddToProjectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addToProjectUIState.message) + } + } + + is AddToProjectUIState.Loaded -> { + var selectedId: HexKey? by remember { mutableStateOf(null) } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + if (translations) { + stringResource(Res.string.add_translation_to_project) + } else { + stringResource(Res.string.add_source_version_to_project) + } + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFab( + label = stringResource( + Res.string.propose_adding_to_the_project + ), + icon = { + Icon(Icons.Default.Add, contentDescription = Decorative) + }, + enabled = selectedId != null && addToProjectUIState.canSign, + onClick = { + val chosen = selectedId ?: return@ExtendedFab + + addToProjectViewModel.addToProject( + localChatRoom = addToProjectUIState.localChatRoom, + selectedId = chosen, + onSuccess = { sessionId -> + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Failed project membership" + ) + ) + } + ) + } + ) + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { Text(addToProjectUIState.project.name) } + + if (!addToProjectUIState.canSign) { + item { + Text( + text = "This group has no shared key, so it cannot sign " + + "anything into this project. Run a shared key " + + "ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + + if (addToProjectUIState.candidates.isEmpty()) { + item { + Text( + if (translations) { + stringResource( + Res.string + .every_translation_is_already_in_this_project + ) + } else { + stringResource( + Res.string + .everything_in_the_library_is_already_in_this_project + ) + } + ) + } + } else { + items( + items = addToProjectUIState.candidates, + key = { candidate -> candidate.id } + ) { candidate -> + Card(onClick = { selectedId = candidate.id }) { + ListItem( + leadingContent = { + Icon( + if (translations) { + Icons.Default.Translate + } else { + Icons.Default.Article + }, + contentDescription = Decorative + ) + }, + trailingContent = { + // One at a time: the proposal is one + // event, and a quorum per thing added + // is the grain the group signs at. + RadioButton( + selected = selectedId == candidate.id, + onClick = { selectedId = candidate.id } + ) + }, + headlineContent = { Text(candidate.label) }, + supportingContent = candidate.detail?.let { detail -> + { Text(detail) } + } + ) + } + } + } + } + } + } + + AddToProjectUIState.Loading -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.project_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } + } + } + } + + LaunchedEffect(true) { + if (initialAddToProjectUIState == AddToProjectUIState.Loading) { + addToProjectViewModel.initiateAddToProject() + } + } +} + +@ConformancePreviews +@Composable +private fun AddToProjectScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddToProjectScreen( + activeUserPublicKey = "a".repeat(64), + projectId = "p".repeat(64), + chatRoomId = "b".repeat(64), + translations = false, + relayHint = null, + 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/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 9ab44345..44bf2b26 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -48,6 +48,8 @@ import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.managers.FrostSigningManager 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.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.MantraTheme @@ -93,6 +95,8 @@ fun FrostSigningScreen( initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, onNavigateBack: () -> Unit, ) { val frostSigningViewModel: FrostSigningViewModel = viewModel( @@ -102,7 +106,9 @@ fun FrostSigningScreen( activeUserPublicKey = activeUserPublicKey, initialFrostSigningUIState = initialFrostSigningUIState, chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository ) ) @@ -169,6 +175,7 @@ fun FrostSigningScreen( events = proposed, expected = state.items.size, chatRoomId = state.localChatRoom.chatRoom.id, + names = state.names, ) HorizontalDivider() @@ -339,7 +346,12 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * sign something they cannot check. */ @Composable -private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: String) { +private fun WhatIsBeingSigned( + events: List, + expected: Int, + chatRoomId: String, + names: Map, +) { if (expected == 0 || events.size != expected) { Text( text = if (expected <= 1) { @@ -365,7 +377,7 @@ private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: St events.forEachIndexed { index, event -> if (index > 0) HorizontalDivider() - OneThingBeingSigned(event, chatRoomId) + OneThingBeingSigned(event, chatRoomId, names) } Text( @@ -380,9 +392,18 @@ private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: St /** One event of the batch, described as the thing it is. */ @Composable -private fun OneThingBeingSigned(event: Event, chatRoomId: String) { - // The room, so a project owned by this group does not say so on every line. - val summary = ProposedEvent.summarize(event, chatRoomId = chatRoomId) +private fun OneThingBeingSigned( + event: Event, + chatRoomId: String, + names: Map, +) { + // The room, so a project owned by this group does not say so on every line; + // and the names, so what is being signed is a thing rather than a hash. + val summary = ProposedEvent.summarize( + event = event, + chatRoomId = chatRoomId, + resolve = names::get, + ) Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) { Text(text = summary.label, style = MaterialTheme.typography.labelMedium) @@ -430,6 +451,8 @@ private fun FrostSigningScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, onNavigateBack = {} ) } 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 7cef5aa1..6f03a595 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 @@ -5,12 +5,14 @@ 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.width import androidx.compose.foundation.layout.height 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.Article +import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api @@ -22,6 +24,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 @@ -31,6 +34,8 @@ 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.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.no_source_versions_in_this_project @@ -47,6 +52,7 @@ import press.mantra.compose.extensions.shortened 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.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.widgets.Decorative @@ -166,6 +172,27 @@ fun ProjectDetailScreen( } } } + item { + TextButton( + enabled = projectDetailUIState.canSign, + onClick = { + onNavigateToRoute.invoke( + AddToProjectRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + translations = false, + relayHint = relayHint, + ) + ) + } + ) { + Icon(Icons.Default.PostAdd, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_source_version_to_project)) + } + } + item { HorizontalDivider() } // Translations @@ -208,6 +235,27 @@ fun ProjectDetailScreen( } } } + item { + TextButton( + enabled = projectDetailUIState.canSign, + onClick = { + onNavigateToRoute.invoke( + AddToProjectRoute( + activeUserPublicKey = activeUserPublicKey, + projectId = project.id, + chatRoomId = chatRoomId, + translations = true, + relayHint = relayHint, + ) + ) + } + ) { + Icon(Icons.Default.Translate, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_translation_to_project)) + } + } + item { HorizontalDivider() } // Who it belongs to 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 499dc57b..64ebcde5 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 @@ -136,6 +136,7 @@ import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen 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.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute @@ -149,6 +150,7 @@ 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 import press.mantra.compose.ui.composable.AddProjectScreen +import press.mantra.compose.ui.composable.AddToProjectScreen import press.mantra.compose.ui.composable.ArtifactDetailScreen import press.mantra.compose.ui.composable.ProjectDetailScreen import press.mantra.compose.ui.composable.AddChapterScreen @@ -1094,6 +1096,39 @@ fun MantraNavHost( }, ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddToProjectScreen( + activeUserPublicKey = route.activeUserPublicKey, + projectId = route.projectId, + chatRoomId = route.chatRoomId, + translations = route.translations, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this picker so back returns to the project + // rather than to a form whose proposal has gone out. + navController.navigate( + route = signingRoute + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -1215,6 +1250,8 @@ fun MantraNavHost( sessionId = route.sessionId, chatRepository = databaseChatRepository, frostSigningRepository = databaseFrostSigningRepository, + mantraRepository = databaseMantraRepository, + nostrRepository = databaseNostrRepository, onNavigateBack = { navController.popBackStack() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt new file mode 100644 index 00000000..d4e48a3b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddToProjectRoute.kt @@ -0,0 +1,20 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * [translations] picks which half of a project this adds to: its translations + * when true, its source versions when false. + * + * One route rather than two because the screen is the same screen -- a list to + * pick from and one proposal -- and the two differ only in which list and which + * kind. Two routes would be two view models with one body between them. + */ +@Serializable +data class AddToProjectRoute( + val activeUserPublicKey: String, + val projectId: String, + val chatRoomId: String, + val translations: Boolean, + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt new file mode 100644 index 00000000..579e233f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModel.kt @@ -0,0 +1,190 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +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.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +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.AddToProjectUIState + +/** + * Adding one more piece of work to a project that already exists. + * + * The other half of `AddProjectViewModel`, and the simpler one: the project's + * id is known, so there is no batch and no lead -- one event, one session. + */ +class AddToProjectViewModel( + val projectId: String, + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val translations: Boolean, + val relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState, + val chatRepository: ChatRepository, + val mantraRepository: MantraRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addToProjectUIState: AddToProjectUIState by mutableStateOf(initialAddToProjectUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddToProject() { + logger.d("initiateAddToProject: $projectId translations=$translations") + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + val project = mantraRepository.getProject(projectId) + + addToProjectUIState = when { + localChatRoom == null -> AddToProjectUIState.Error("Couldn't find the chat room") + project == null -> AddToProjectUIState.Error("Couldn't find the project") + else -> AddToProjectUIState.Loaded( + localChatRoom = localChatRoom, + project = project, + candidates = candidatesFor(localChatRoom.chatRoom.id), + canSign = frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * What the room has that the project does not already hold. + * + * Filtered rather than shown and refused: a duplicate membership is a + * second event with its own id, so nothing downstream would stop it -- it + * would just cost a quorum to say what the group has already said. + */ + private suspend fun candidatesFor(chatRoomId: String): List { + val versions = mantraRepository.getArtifacts(chatRoomId) + .flatMap { mantraRepository.getArtifactVersions(it.id) } + + return if (translations) { + val already = mantraRepository.getProjectTranslationArtifactVersions(projectId) + .map { it.id } + .toSet() + + versions + .flatMap { mantraRepository.getTranslationsForArtifactVersion(it.id) } + .filterNot { it.id in already } + .map { + AddToProjectUIState.Candidate( + id = it.id, + label = it.name, + detail = null, + ) + } + } else { + val already = mantraRepository.getProjectArtifactVersions(projectId) + .map { it.id } + .toSet() + + versions + .filterNot { it.id in already } + .map { version -> + AddToProjectUIState.Candidate( + id = version.id, + label = version.versionLabel, + // The artifact, because a version label on its own is + // "1.0" and a room has several of those. + detail = mantraRepository.getArtifact(version.artifactId)?.name, + ) + } + } + } + + /** + * Asks the group to sign one thing into the project. + * + * One event, so [FrostSigningRepository.proposeSigning]'s flat batch rather + * than the lead-and-dependents form: this screen knows the project's id + * because the project exists. + */ + fun addToProject( + localChatRoom: LocalChatRoom, + selectedId: HexKey, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val template = if (translations) { + ProjectTranslationArtifactVersionEvent.build( + projectId = projectId, + translationArtifactVersionId = selectedId, + ) + } else { + ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = selectedId, + ) + } + + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = listOf(template), + ) + }.onFailure { error -> + logger.e("Failed to propose adding to project $projectId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) onSuccess.invoke(session.id) else onFailure.invoke() + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddToProjectViewModel" + + fun factory( + activeUserPublicKey: HexKey, + projectId: String, + chatRoomId: String, + translations: Boolean, + relayHint: String?, + initialAddToProjectUIState: AddToProjectUIState = AddToProjectUIState.Loading, + chatRepository: ChatRepository, + mantraRepository: MantraRepository, + frostSigningRepository: FrostSigningRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddToProjectViewModel( + activeUserPublicKey = activeUserPublicKey, + projectId = projectId, + chatRoomId = chatRoomId, + translations = translations, + relayHint = relayHint, + initialAddToProjectUIState = initialAddToProjectUIState, + chatRepository = chatRepository, + mantraRepository = mantraRepository, + frostSigningRepository = frostSigningRepository, + ) + } + } + } +} 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 3dc63459..e533a1e3 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 @@ -19,8 +19,13 @@ import kotlinx.coroutines.launch 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.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent 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.view.state.FrostSigningUIState class FrostSigningViewModel( @@ -30,6 +35,8 @@ class FrostSigningViewModel( initialFrostSigningUIState: FrostSigningUIState, val chatRepository: ChatRepository, val frostSigningRepository: FrostSigningRepository, + val mantraRepository: MantraRepository, + val nostrRepository: NostrRepository, ): ViewModel() { var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState) @@ -72,12 +79,83 @@ class FrostSigningViewModel( signed = messages .filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE } .map { it.signerPublicKey } - .toSet() + .toSet(), + names = namesIn(proposedEvents(items)) ) } } } + /** + * Whatever the proposed events refer to, as words. + * + * Only the kinds that need it, and only what this device already holds. A + * key with no profile and an id with no row are both left out, and + * `ProposedEvent.summarize` falls back to a short hash -- which is truthful + * rather than a gap: the device has not seen the thing. + * + * A project's own id resolves to nothing while it is being proposed, + * because nothing has applied it yet. That is why a membership says the + * version first. + */ + private suspend fun namesIn(events: List): Map = buildMap { + suspend fun person(publicKey: HexKey) { + if (containsKey(publicKey)) return + nostrRepository.getProfileWithPublicKey(publicKey)?.let { + put(publicKey, it.humanReadableNameOrPubkey()) + } + } + + suspend fun project(projectId: HexKey?) { + if (projectId == null || containsKey(projectId)) return + mantraRepository.getProject(projectId)?.let { put(projectId, it.name) } + } + + events.forEach { event -> + when (event.kind) { + ProjectEvent.KIND -> { + val proposed = ProjectEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + proposed.creator()?.let { person(it) } + proposed.owners().forEach { person(it) } + } + + ProjectArtifactVersionEvent.KIND -> { + val membership = ProjectArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + membership.artifactVersionId()?.let { versionId -> + mantraRepository.getArtifactVersion(versionId)?.let { version -> + val artifact = mantraRepository.getArtifact(version.artifactId) + put( + versionId, + listOfNotNull(artifact?.name, version.versionLabel) + .joinToString(" ") + ) + } + } + project(membership.projectId()) + } + + ProjectTranslationArtifactVersionEvent.KIND -> { + val membership = ProjectTranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, + event.tags, event.content, event.sig + ) + membership.translationArtifactVersionId()?.let { translationId -> + mantraRepository.getTranslation(translationId)?.let { + put(translationId, it.name) + } + } + project(membership.projectId()) + } + } + } + } + /** The events the group is being asked to sign, for showing them before they agree. */ fun proposedEvents(items: List): List = items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) } @@ -133,6 +211,8 @@ class FrostSigningViewModel( initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading, chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, + mantraRepository: MantraRepository, + nostrRepository: NostrRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { FrostSigningViewModel( @@ -141,7 +221,9 @@ class FrostSigningViewModel( activeUserPublicKey = activeUserPublicKey, initialFrostSigningUIState = initialFrostSigningUIState, chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository + frostSigningRepository = frostSigningRepository, + mantraRepository = mantraRepository, + nostrRepository = nostrRepository ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt new file mode 100644 index 00000000..edb3862f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddToProjectUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface AddToProjectUIState { + /** + * [candidates] is whatever is not already in the project: the room's source + * versions or its translations, depending on which half this screen is for. + * + * Already-included work is filtered out rather than shown and refused. A + * membership that duplicates one the group has already signed is a second + * event with a different id, so nothing would stop it -- it would simply + * cost a quorum to say what the group has already said. + */ + data class Loaded( + val localChatRoom: LocalChatRoom, + val project: MantraProject, + val candidates: List = emptyList(), + val canSign: Boolean = false, + ): AddToProjectUIState + + /** One thing that could be added, named the way the picker shows it. */ + data class Candidate(val id: HexKey, val label: String, val detail: String?) + + data class Error( + val message: String + ): AddToProjectUIState + + data object Loading: AddToProjectUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt index 515a603f..519d1efa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt @@ -23,6 +23,22 @@ sealed interface FrostSigningUIState { /** Who has signed their part. */ val signed: Set = emptySet(), + + /** + * Names for the keys and ids the proposed events refer to. + * + * `ProposedEvent.summarize` is a pure function with no database -- two + * call sites show these words and two copies of the mapping would drift + * -- so whatever can be named is resolved here and handed in as a + * lookup. + * + * It matters most for a membership, which carries two 64-character ids + * and nothing else: unresolved, a member is asked to agree to "sign + * a4f1be55d0c3". Empty is the honest default, and what a batch founding + * a project sees for the project's own id, since nothing has applied it + * yet. + */ + val names: Map = emptyMap(), ): FrostSigningUIState data class Error( From aceef313ed0251ad343cea9019e5ae57b1aabd13 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:50:41 +0200 Subject: [PATCH 19/32] docs: record what building projects changed about the plan Five places the build chose differently, and one thing the plan did not anticipate: a project reaches a chronicle only off the GroupSignedEvent record, because the deprecated rebuild has no `toProjectEvent` and deliberately never will. `recordSignedEvents` catches its own failure so a ceremony that succeeded is not reported as failed, which leaves a narrow window where the row exists and the signed event does not -- a project this device can show and can never hand on. There is a test holding that case open. Co-Authored-By: Claude Opus 5 --- docs/README.md | 6 ++++-- docs/project-feature.md | 24 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 0e94825d..15ddd053 100644 --- a/docs/README.md +++ b/docs/README.md @@ -73,10 +73,12 @@ smallest of the plans: it changes the entrance to the direct-message flow and nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and `ChatRoomMessagingViewModel.initiateNewChat` open, its four decisions before its four phases, and its table of where the build chose differently first. -The projects note is a phased plan that has not been built, and is the only one +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, `mantra-db-models`, has had the three models for years. It reads as the translation domain's unanswered question — the app can say what a chapter is and who translated it, and cannot say what any of it is for — so read it after the nip30303 conventions are familiar, its decisions before its phases, and its scope -fork before either, because that one changes the shape of the rest. +fork before either, because that one changes the shape of the rest, and its +table of where the build chose differently first. diff --git a/docs/project-feature.md b/docs/project-feature.md index 07387781..45b5807a 100644 --- a/docs/project-feature.md +++ b/docs/project-feature.md @@ -1,6 +1,28 @@ # Projects -A phased plan, not yet built. +Phases 0 through 7 are built. Phases 8 and 9 -- removing something from a +project, and projects that span rooms -- are not, and the reasoning for both is +below rather than anywhere else. + +**Where the build chose differently**, each of which is explained in the phase +it belongs to: + +| the plan said | the build did | why | +|---|---|---| +| a `creator` tag and an `owner` tag | a `creator` tag, and owners as `ContributorTag`s with `ROLE_OWNER` | a project has several owners, which is the cardinality `ContributorTag` is for | +| the room's project section is new | it replaced a stub | `ChatRoomDetailScreen` already had a heading, a permanent empty line and an `ImplementationPendingRoute`, under a `// TODO: Add projects...` | +| the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` | +| three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot | +| around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub | + +The one thing found while building that the plan did not anticipate: a project +reaches a chronicle only off the `GroupSignedEvent` record, because there is no +`toProjectEvent` rebuild. `recordSignedEvents` catches its own failure so that a +ceremony that succeeded is not reported as failed, which leaves a narrow window +where a project row exists and its signed event does not -- a project this +device can show and can never hand on. `ChronicleAssemblyJvmTest.a project is +only ever chronicled off the record` holds that case open rather than letting it +be discovered later. `mantra-db-models` — the platform's server-side Sequelize schema — has had three models waiting for this app since before it existed: `project`, with a name and a From 1673ce0e5edf6a7403a460893b9832ac919cad01 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:53:54 +0200 Subject: [PATCH 20/32] test(chapters): an upload end to end, and the callback it found missing Phase 9 of docs/chapter-uploads.md. ChapterUploadJvmTest drives the view model over a real database: five files become five sessions, one chapter leading its own chunks in each -- never two chapters sharing a batch -- numbered contiguously from zero, which the old `.size` numbering could not do because nothing is signed while the pass runs. Each chapter's chunks are the blocks of its own file, markers off. Four chapters in a three-dialect group ask for one scaffolding session of twelve rows, not four. It found one thing: propose() with nothing proposable returned without ever calling its callback. The FAB is disabled in that case so the screen never reached it, but a callback that sometimes never fires is a trap for the next caller, so it now fires onDone(0, 0). That is the whole reason for writing the end-to-end test rather than assuming the parts composed. One thing it deliberately does not test the way the plan asked. "A failure on file 4 leaves files 1-3 proposed" is unreachable by input: both caps are checked in the form against the same functions the manager enforces, and ChapterProposalSizeTest asserts the form's estimate is never under the manager's measurement, so no file passes the review and then fails the proposal. It is reachable by state, which is what the test does -- a room with no shared key fails all three, every row says so and stays retryable, and a second pass after the key arrives proposes all three from zero, the failed pass having spent no indices because it created nothing. 995 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../ui/view/model/UploadChaptersViewModel.kt | 12 +- .../compose/managers/ChapterUploadJvmTest.kt | 433 ++++++++++++++++++ docs/chapter-uploads.md | 36 +- 3 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterUploadJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt index 5558c7c3..a32f752f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UploadChaptersViewModel.kt @@ -198,10 +198,20 @@ class UploadChaptersViewModel( onDone: (proposed: Int, failed: Int) -> Unit, ) { val loaded = _uiState.value as? UploadChaptersUIState.Loaded ?: return + + // A pass already running will call [onDone] itself when it finishes; + // firing it here as well would announce one pass twice. if (loaded.isProposing) return val queue = loaded.proposable.map { it.id } - if (queue.isEmpty()) return + if (queue.isEmpty()) { + // Nothing to do still has to be said. The FAB is disabled in this + // case so the screen never reaches it, but a callback that + // sometimes never fires is a trap for the next caller -- and it is + // what a test of a second tap found. + onDone(0, 0) + return + } updateLoaded { it.copy(isProposing = true) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterUploadJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterUploadJvmTest.kt new file mode 100644 index 00000000..50a44b3e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterUploadJvmTest.kt @@ -0,0 +1,433 @@ +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.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +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.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +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.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseFrostSigningRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.extensions.toHex +import press.mantra.compose.files.PickedFile +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.nostr.nip30303.TranslationChapterEvent +import press.mantra.compose.text.Markdown +import press.mantra.compose.ui.view.model.UploadChaptersViewModel +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadProblem + +/** + * An upload, end to end, over a real database. + * + * `UploadReviewTest` covers what a file is offered as, which is pure. This is + * the orchestration: how many sessions a group is asked for, what each one + * signs, what index each chapter takes, and -- the part with no second chance -- + * what is left behind when a pass only half works. + * + * Nothing can retract a proposal, so partial success is the expected outcome + * rather than an error, and the only thing that makes it recoverable is the + * screen keeping an honest list of what went and what did not. + */ +class ChapterUploadJvmTest { + private val participants = 3 + private val threshold = 2 + + 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 val dialectId = "d".repeat(64) + private val artifactId = "a1".padEnd(64, '0') + private val artifactVersionId = "b".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var viewModel: UploadChaptersViewModel + + @BeforeTest + fun setUpMain() { + // viewModelScope dispatches on Dispatchers.Main, which a jvm test has + // none of. Unconfined rather than a test scheduler: the view model does + // its work on Dispatchers.IO and uses Main only to hand a result back, + // so there is nothing here to advance -- the test waits on the callback. + Dispatchers.setMain(Dispatchers.Unconfined) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + if (::db.isInitialized) db.close() + } + + /** Opens a device, with the group's shared key present only if [withKey]. */ + private suspend fun openDevice(withKey: Boolean = true, translations: Int = 0) { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + 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)) + + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = proposer, + subject = "#Ekklesia", + description = SharedKeyDerivation.describe("Admins of the group."), + mlsGroupState = null, + ) + ) + + if (withKey) giveTheGroupItsKey() + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Setswana", + country = "BW", + language = "tn", + signature = "", + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = "To Kill a Mockingbird", + url = "https://example.test/mockingbird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = "", + ) + ) + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = artifactVersionId, + artifactId = artifactId, + publicKey = roomId, + versionLabel = "1.0", + chatRoomId = roomId, + signature = "", + ) + ) + (0 until translations).forEach { ordinal -> + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = "translation$ordinal".padEnd(64, '0'), + publicKey = roomId, + artifactVersionId = artifactVersionId, + dialectId = dialectId, + name = "Translation $ordinal", + visibility = "private", + license = "cc", + signature = "", + chatRoomId = roomId, + ) + ) + } + + viewModel = UploadChaptersViewModel( + artifactId = artifactId, + chatRoomId = roomId, + activeUserPublicKey = proposer, + relayHint = null, + initialUIState = UploadChaptersUIState.Loading, + mantraRepository = DatabaseMantraRepository(db, scope), + chatRepository = DatabaseChatRepository(db, scope), + frostSigningRepository = DatabaseFrostSigningRepository(db, scope), + ) + viewModel.initiate() + awaitLoaded() + } + + private suspend fun giveTheGroupItsKey() { + 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], + ) + ) + } + } + + private suspend fun awaitLoaded(): UploadChaptersUIState.Loaded = + withTimeout(10_000) { + var state = viewModel.uiState.value + while (state !is UploadChaptersUIState.Loaded) { + kotlinx.coroutines.delay(10) + state = viewModel.uiState.value + } + state + } + + private val loaded: UploadChaptersUIState.Loaded + get() = viewModel.uiState.value as UploadChaptersUIState.Loaded + + private fun file(name: String, text: String) = + PickedFile(name = name, bytes = text.encodeToByteArray()) + + /** A chapter of [paragraphs] paragraphs, as a file would hold it. */ + private fun chapterFile(ordinal: Int, paragraphs: Int = 2) = file( + name = "chapter-$ordinal.md", + text = (1..paragraphs).joinToString("\n\n") { "Paragraph $it of chapter $ordinal." }, + ) + + /** Proposes, and waits for the pass to finish. */ + private suspend fun propose(): Pair { + val done = CompletableDeferred>() + viewModel.propose( + localChatRoom = loaded.localChatRoom, + artifactVersion = loaded.artifactVersion!!, + ) { proposed, failed -> done.complete(proposed to failed) } + return withTimeout(30_000) { done.await() } + } + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + private suspend fun sessionsInRoom(): List = + db.frostSigningSessionDao().getSessionsForChatRoom(roomId).map { it.id } + + @Test + fun `five files become five sessions, one chapter each, numbered in order`() = runBlocking { + openDevice() + + viewModel.addFiles((1..5).map { chapterFile(it, paragraphs = 3) }) + assertEquals(5, loaded.chapters.size) + + val (proposed, failed) = propose() + + assertEquals(5 to 0, proposed to failed) + + val chapterSessions = loaded.chapters.map { it.sessionId!! } + assertEquals(5, chapterSessions.toSet().size) + + // Each session is one chapter leading its own chunks -- never two + // chapters sharing a batch, which is the whole shape of this feature. + val chapters = chapterSessions.map { session -> + val events = itemEvents(session) + assertEquals(ChapterEvent.KIND, events.first().kind) + assertTrue(events.drop(1).all { it.kind == ChunkEvent.KIND }) + assertEquals(4, events.size) // the chapter and its three chunks + ChapterEvent( + events.first().id, + events.first().pubKey, + events.first().createdAt, + events.first().tags, + events.first().content, + events.first().sig, + ) + } + + // Contiguous from zero, which the old `.size` numbering could not do: + // nothing is signed, so the table is empty for all five. + assertEquals(listOf(0, 1, 2, 3, 4), chapters.map { it.index() }) + assertEquals( + (1..5).map { "chapter-$it" }, + chapters.map { it.name() }, + ) + } + + @Test + fun `each chapter's chunks are the blocks of its own file`() = runBlocking { + openDevice() + + val text = "# A heading\n\nFirst paragraph.\n\n- one\n- two\n" + viewModel.addFiles(listOf(file("ch.md", text))) + propose() + + val events = itemEvents(loaded.chapters.single().sessionId!!) + val chunks = events.drop(1).map { it.content } + + assertEquals(Markdown.splitChunks(text), chunks) + // And the block markers are off, which is what Phase 8 changed. + assertEquals(listOf("A heading", "First paragraph.", "one", "two"), chunks) + } + + @Test + fun `the whole upload asks for one scaffolding session, not one per chapter`() = runBlocking { + openDevice(translations = 3) + + viewModel.addFiles((1..4).map { chapterFile(it) }) + propose() + + val chapterSessions = loaded.chapters.map { it.sessionId!! }.toSet() + val scaffolding = sessionsInRoom().filterNot { it in chapterSessions } + + assertEquals(1, scaffolding.size) + + val rows = itemEvents(scaffolding.single()) + assertEquals(12, rows.size) // four chapters x three dialects + assertTrue(rows.all { it.kind == TranslationChapterEvent.KIND }) + } + + @Test + fun `a pass that fails leaves every row listed with its reason and retryable`() = runBlocking { + // No shared key, so every proposal throws inside the view model. This is + // the reachable way to half-fail a pass: the caps are checked in the + // form, so no input gets past the review and then fails the manager. + openDevice(withKey = false) + + viewModel.addFiles((1..3).map { chapterFile(it) }) + + val (proposed, failed) = propose() + assertEquals(0 to 3, proposed to failed) + + // Nothing proposed, nothing lost, and each row says why. + assertEquals(emptyList(), sessionsInRoom()) + assertTrue(loaded.chapters.all { it.sessionId == null }) + assertTrue(loaded.chapters.all { it.problem == UploadProblem.ProposalFailed }) + + // ProposalFailed does not block, because a retry is a new proposal -- + // which is the only correct way to retry a FROST session. + assertEquals(3, loaded.proposable.size) + } + + @Test + fun `retrying after a failure proposes the rest and numbers them from zero`() = runBlocking { + openDevice(withKey = false) + + viewModel.addFiles((1..3).map { chapterFile(it) }) + propose() + + giveTheGroupItsKey() + + val (proposed, failed) = propose() + + assertEquals(3 to 0, proposed to failed) + assertTrue(loaded.chapters.all { it.problem == null }) + + val indices = loaded.chapters.map { row -> + val lead = itemEvents(row.sessionId!!).first() + ChapterEvent( + lead.id, lead.pubKey, lead.createdAt, lead.tags, lead.content, lead.sig + ).index() + } + + // The failed pass spent no indices, because it created nothing. + assertEquals(listOf(0, 1, 2), indices) + } + + @Test + fun `a row already proposed is not proposed again`() = runBlocking { + openDevice() + + viewModel.addFiles(listOf(chapterFile(1))) + propose() + val first = loaded.chapters.single().sessionId + + // Nothing left to do, so a second tap proposes nothing rather than + // asking the group to sign the same chapter twice. + val (proposed, failed) = propose() + + assertEquals(0 to 0, proposed to failed) + assertEquals(first, loaded.chapters.single().sessionId) + assertEquals(1, sessionsInRoom().size) + } + + @Test + fun `a file that is not text is never proposed and does not stop the others`() = runBlocking { + openDevice() + + viewModel.addFiles( + listOf( + chapterFile(1), + PickedFile("cover.png", byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x00)), + chapterFile(2), + ) + ) + + val (proposed, failed) = propose() + + // Two proposed, and the third is not a failure -- it was never offered. + assertEquals(2 to 0, proposed to failed) + assertEquals(UploadProblem.NotText, loaded.chapters[1].problem) + assertNull(loaded.chapters[1].sessionId) + assertEquals(2, loaded.chapters.count { it.sessionId != null }) + } + + @Test + fun `the soft cap is on the upload, not on the artifact`() = runBlocking { + openDevice() + + viewModel.addFiles((1..UploadChaptersViewModel.MAX_FILES_PER_UPLOAD + 5).map { chapterFile(it) }) + + // The extra files are dropped at the door rather than listed and + // refused: a limit on how much of the room's attention one tap spends. + assertEquals(UploadChaptersViewModel.MAX_FILES_PER_UPLOAD, loaded.chapters.size) + } +} diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index ba1d81cf..8b6471f2 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -5,10 +5,19 @@ Picking files off a device and having a group sign each one into an artifact as [mantra.press](https://code.sigidli.com/mantra/mantra.press) does it over one HTTP POST, except that here nobody is authoring anything on their own. -**Not built.** This is a plan. The constraint that shapes every phase is stated -first, because the web app's shape — N files in, N chapters out, one -transaction, one redirect — is the one shape this app cannot have, and every -phase below is a consequence of that rather than a feature. +**Built**, all nine phases, 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 from the plan the section says so, and there +are four such places worth knowing about: the picker is three actuals rather +than FileKit, the byte cap is a function rather than a constant and lives on the +manager rather than the chapter, the scaffolding fix turned out to repair a +latent bug as well, and Phase 6 found all three of its worries already answered. + +The constraint that shapes every phase is stated first, because the web app's +shape — N files in, N chapters out, one transaction, one redirect — is the one +shape this app cannot have, and every phase below is a consequence of that +rather than a feature. --- @@ -961,6 +970,25 @@ file 4 leaves files 1–3 proposed and recoverable rather than rolled back. `jvmTest`, not `commonTest`, because it is Room-backed; `./gradlew :composeApp:jvmTest`, per the project's build notes. +**What the end-to-end test found**, which is the reason it was worth writing +rather than assuming the parts composed: `propose` with nothing proposable +returned without ever calling its callback. The FAB is disabled in that case so +the screen never reached it, but a callback that sometimes never fires is a trap +for the next caller. It now fires `onDone(0, 0)`. + +**And one thing it could not test, for a good reason.** The plan asked for "a +failure on file 4 leaves files 1–3 proposed and recoverable". That is +unreachable by *input*: both caps are checked in the form against the same +functions the manager enforces, and `ChapterProposalSizeTest` asserts the form's +estimate is never under the manager's measurement — so no file can pass the +review and then fail the proposal. It is reachable by *state*, which is what the +test does instead: a room with no shared key fails all three, every row says so +and stays retryable, and a second pass after the key arrives proposes all three +numbered from zero. The failed pass spent no indices, because it created +nothing. + +Total: 995 tests, and the counts per phase are in each commit. + Rollout needs no flag. The chapter and chunk kinds, their tags and their apply branches are unchanged, so an older build receives an uploaded chapter as the chapter it is. Two things do change what a peer sees, and neither needs From a0bad7c8da6c6125df6241182e88114da88047c2 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:54:03 +0200 Subject: [PATCH 21/32] docs: the chapter uploads plan has been built Co-Authored-By: Claude Opus 5 --- docs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index e117b41b..baf7f935 100644 --- a/docs/README.md +++ b/docs/README.md @@ -73,7 +73,7 @@ smallest of the plans: it changes the entrance to the direct-message flow and nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and `ChatRoomMessagingViewModel.initiateNewChat` open, its four decisions before its four phases, and its table of where the build chose differently first. -The chapter uploads note is a phased plan that has not been built, and is the +The chapter uploads note is a phased plan that has been built, and is the only one that starts from another codebase: it mirrors the mantra.press web app's multi-file upload under this app's rule that the group, not the uploader, is the author. Read the batch-signing note first — its three limits are the whole shape From 2fb8d6921b92a0add56e28a5c60ca26024e56e17 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:05:08 +0200 Subject: [PATCH 22/32] fix(projects): let a membership's foreign key violation out, so the sweep retries A project's memberships are foreign keys into both the project and the version they name, so a chronicle page carrying a membership whose project is in a *later* page cannot apply it yet. `ChronicleManager.applyPage` handles exactly this: it catches the violation, counts the payload `failed`, and `sweep` re-runs every page until failures stop falling. Phase 3 broke that for projects by catching the violation inside the apply arm. `applyInnerEvent` then returned normally, `applyPage` counted the payload applied, the sweep saw nothing left to do, and the membership was dropped -- silently, permanently, on the device a chronicle exists for. The catch was added so that `FrostSigningManager.applySignedEvent` would not report a succeeded session as failed. That reasoning was already satisfied: `applySignedEvent` wraps the call in its own try/catch. And no sibling arm catches -- a translation chapter naming an absent chapter throws today -- so the catch made projects the only kind a chronicle could not retry. It is gone. Pages are 128 events or 64 KiB, and projects sort last in APPLY_ORDER, so a boundary falling between a project and its memberships is the ordinary case for any room with a chapter's worth of chunks in it. `pages arriving out of order still converge` already modelled this -- one page per payload, delivered backwards, comparing every row count on both devices -- and passed anyway, because `rowCounts` did not look at the project tables. It does now, which is what makes that test the one that would have caught this. `ProjectApplyJvmTest` asserts the throw rather than the swallow, and adds the retry in miniature: refused while the project is missing, accepted once it is there, same event both times. 1404 tests, no failures. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 63 ++++++++----------- .../database/model/ProjectApplyJvmTest.kt | 52 ++++++++++++--- .../compose/managers/ChronicleApplyJvmTest.kt | 26 +++++++- docs/project-feature.md | 19 ++++-- 4 files changed, 109 insertions(+), 51 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 944536ce..3552e6ca 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -1330,13 +1330,23 @@ data class ChatMessage( // writes "The group signed ..." when the session completes, and // three more lines here would be a second account of it. // - // The row may be refused: both references are foreign keys, so a - // membership naming a version this device does not hold cannot be - // written. That is the one-room scope decision doing its job - // rather than an error to propagate -- `applySignedEvent` - // swallows throwables because the signature is valid either way, - // and letting this one reach it would report a session that - // succeeded as a session that failed. + // **The foreign key violation is allowed out, and has to be.** + // Both references are keys, so a membership naming a version or + // a project this device does not hold cannot be written -- and + // `ChronicleManager.applyPage` learns that only by catching it. + // It counts the payload failed, and `sweep` runs the pages again + // until failures stop falling, which is the whole mechanism that + // delivers a membership whose project landed in a *later* page. + // Swallowing it here counts the payload as applied, the sweep + // terminates, and the membership is lost silently on the one + // device that has nothing else to fall back on. + // + // It was caught here for one commit, on the reasoning that + // `applySignedEvent` must not report a succeeded session as + // failed. That reasoning was already satisfied: it has its own + // try/catch. And no sibling arm catches -- a translation chapter + // naming an absent chapter throws today -- so catching made this + // the only kind the chronicle could not retry. ProjectArtifactVersionEvent.KIND -> { MantraProjectArtifactVersion.fromProjectArtifactVersionEvent( projectArtifactVersionEvent = ProjectArtifactVersionEvent( @@ -1349,21 +1359,12 @@ data class ChatMessage( ), chatRoomId = groupId, )?.let { membership -> - try { - database.mantraProjectArtifactVersionDao().upsert( - membership.copy( - marmotGroupEventId = marmotGroupEventId, - groupSignedEventId = groupSignedEventId, - ) + database.mantraProjectArtifactVersionDao().upsert( + membership.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, ) - } catch (error: Throwable) { - logger.w( - "Project ${membership.projectId.take(8)} names artifact " + - "version ${membership.artifactVersionId.take(8)}, which " + - "this device does not hold", - error - ) - } + ) } null } @@ -1383,22 +1384,12 @@ data class ChatMessage( ), chatRoomId = groupId, )?.let { membership -> - try { - database.mantraProjectTranslationArtifactVersionDao().upsert( - membership.copy( - marmotGroupEventId = marmotGroupEventId, - groupSignedEventId = groupSignedEventId, - ) + database.mantraProjectTranslationArtifactVersionDao().upsert( + membership.copy( + marmotGroupEventId = marmotGroupEventId, + groupSignedEventId = groupSignedEventId, ) - } catch (error: Throwable) { - logger.w( - "Project ${membership.projectId.take(8)} names " + - "translation " + - membership.translationArtifactVersionId.take(8) + - ", which this device does not hold", - error - ) - } + ) } null } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt index 1d90b48a..fbdf9af4 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/model/ProjectApplyJvmTest.kt @@ -19,6 +19,7 @@ import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFails import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail @@ -295,8 +296,25 @@ class ProjectApplyJvmTest { ) } + /** + * The refusal is **allowed out**, and that is the whole of why it matters. + * + * `ChronicleManager.applyPage` learns that a payload could not be applied + * only by catching the exception: it counts the payload failed, and `sweep` + * runs the pages again until failures stop falling. That loop is what + * delivers a membership whose project arrived in a *later* page. Swallow the + * violation here and the payload counts as applied, the sweep terminates, + * and the membership is lost silently -- on the one device that has nothing + * to fall back on. + * + * It was swallowed here for one commit, on the reasoning that + * `FrostSigningManager.applySignedEvent` must not report a succeeded session + * as failed. That reasoning was already satisfied -- it has its own + * try/catch -- and no sibling arm catches, so catching made this the only + * kind a chronicle could not retry. + */ @Test - fun `a membership naming a version this device lacks leaves the project standing`() = + fun `a membership naming a version this device lacks throws, and spares the project`() = runBlocking { seed() val project = signed(projectTemplate()) @@ -307,11 +325,7 @@ class ProjectApplyJvmTest { ProjectArtifactVersionEvent.membershipsOf(project, listOf(absent)).single() ) - // The foreign key refuses it. What must not happen is the refusal - // escaping: `applySignedEvent` would report a session that succeeded - // as one that failed, and the group would be told to abandon a - // ceremony whose signature is perfectly good. - assertNull(apply(source)) + assertFails { apply(source) } assertTrue( db.mantraProjectArtifactVersionDao().getByProjectId(project.id).isEmpty() @@ -323,7 +337,7 @@ class ProjectApplyJvmTest { } @Test - fun `a membership naming a project this device lacks is refused the same way`() = + fun `a membership naming a project this device lacks throws the same way`() = runBlocking { seed() val absentProject = "8".repeat(64) @@ -334,12 +348,34 @@ class ProjectApplyJvmTest { createdAt = 1_000, ) - assertNull(apply(signed(template))) + assertFails { apply(signed(template)) } assertTrue( db.mantraProjectArtifactVersionDao().getByProjectId(absentProject).isEmpty() ) } + @Test + fun `a membership that arrives after its project applies cleanly`() = runBlocking { + seed() + val project = signed(projectTemplate()) + val source = signed( + ProjectArtifactVersionEvent.membershipsOf(project, listOf(artifactVersionId)).single() + ) + + // The retry the sweep performs, in miniature: refused while the project + // is missing, accepted once it is there. Same event both times. + assertFails { apply(source) } + apply(project) + apply(source) + + assertEquals( + listOf("1.0"), + db.mantraProjectArtifactVersionDao() + .getArtifactVersionsByProjectId(project.id) + .map { it.versionLabel } + ) + } + @Test fun `a membership missing the project it belongs to writes nothing`() = runBlocking { seed() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt index cd0eb5de..8036a603 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleApplyJvmTest.kt @@ -414,6 +414,23 @@ class ChronicleApplyJvmTest { db.mantraTranslationChunkDao().getTranslationChunksByTranslationChapterId(it.id) } + // The projects, and what each one holds. + // + // Counted here rather than in a test of their own, because the test that + // matters most for them is `pages arriving out of order still converge` + // -- and that test compares exactly this map. A membership is a foreign + // key into both a project and a version, so delivering backwards is the + // scenario where one gets refused and has to be retried, and this map not + // looking at the project tables is why that test once passed against an + // apply arm that swallowed the refusal and lost the row. + val projects = db.mantraProjectDao().getProjectsByChatRoomId(chatRoomId) + val projectSources = projects.flatMap { + db.mantraProjectArtifactVersionDao().getByProjectId(it.id) + } + val projectTranslations = projects.flatMap { + db.mantraProjectTranslationArtifactVersionDao().getByProjectId(it.id) + } + return mapOf( "dialects" to dialects.size, "artifacts" to artifacts.size, @@ -423,6 +440,9 @@ class ChronicleApplyJvmTest { "translationVersions" to translationVersions.size, "translationChapters" to translationChapters.size, "translationChunks" to translationChunks.size, + "projects" to projects.size, + "projectSources" to projectSources.size, + "projectTranslations" to projectTranslations.size, ) } @@ -473,7 +493,8 @@ class ChronicleApplyJvmTest { mapOf( "dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0, "chunks" to 0, "translationVersions" to 0, "translationChapters" to 0, - "translationChunks" to 0, + "translationChunks" to 0, "projects" to 0, "projectSources" to 0, + "projectTranslations" to 0, ), rowCounts(receiver), "the new member starts with nothing, which is the whole problem" @@ -731,7 +752,8 @@ class ChronicleApplyJvmTest { mapOf( "dialects" to 0, "artifacts" to 0, "versions" to 0, "chapters" to 0, "chunks" to 0, "translationVersions" to 0, "translationChapters" to 0, - "translationChunks" to 0, + "translationChunks" to 0, "projects" to 0, "projectSources" to 0, + "projectTranslations" to 0, ), rowCounts(receiver), "a bystander already holds the work; re-applying would only rewrite its provenance" diff --git a/docs/project-feature.md b/docs/project-feature.md index 45b5807a..5e73a85e 100644 --- a/docs/project-feature.md +++ b/docs/project-feature.md @@ -14,6 +14,7 @@ it belongs to: | the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` | | three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot | | around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub | +| the apply arms catch the foreign key violation | they let it out | `ChronicleManager.sweep` retries a page only on a *counted* failure, so catching it loses a membership that crosses a page boundary — and `applySignedEvent`, the reason for catching, already has its own try/catch | The one thing found while building that the plan did not anticipate: a project reaches a chronicle only off the `GroupSignedEvent` record, because there is no @@ -725,11 +726,19 @@ already writes "The group signed …" when a session completes, which is the lin that covers it. Three new strings for a transcript the signing session is already narrating would be three places for the same event to be described differently. -Both membership arms must tolerate the version not being here. The FK will refuse -the insert; catch it, log it, and return null rather than letting it escape — -`applySignedEvent` swallows throwables for a reason (the signature is valid -whether or not this device could write the row down) but a refusal that reaches it -is reported as a failed apply on a session that succeeded. +**Both membership arms let the foreign key violation out, and must.** The +temptation is to catch it — `applySignedEvent` swallows throwables so a succeeded +session is not reported as failed — and that is what was built first. It was wrong +twice over. `applySignedEvent` already has its own try/catch, so the reasoning was +satisfied without doing anything; and `ChronicleManager.applyPage` learns a +payload could not be applied **only** by catching the exception, counts it +`failed`, and `sweep` re-runs the pages until failures stop falling. That loop is +the entire mechanism for a membership whose project arrived in a later page. +Swallowing it counts the payload as applied, the sweep terminates, and the row is +lost silently on the one device with nothing to fall back on. + +No sibling arm catches either — a translation chapter naming an absent chapter +throws today — so catching made this the only kind a chronicle could not retry. **Tests** (`jvmTest`): a `ProjectApplyJvmTest` in the shape of `TranslationChunkApplyJvmTest` — apply a project with three owners and assert From 26d305bc143a94d41ae8957ad99659e7c294d8a5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:12:43 +0200 Subject: [PATCH 23/32] fix(subgroups): a parent's second subgroup can be certified, and sign its own key state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both rungs after a subgroup's ceremony run in the **parent's** room. The birth certificate is the parent's signature, so it is signed where the parent's key is; the key state is signed over the parent's room because the room it is *about* does not exist yet -- that is the whole point of signing it first. So a parent making its second subgroup has two certificate signings and two key-state signings in one room, and the ceremony screen read them out by event kind alone. `observeSessionsForChatRoom` is `ORDER BY createdAt DESC`, so `firstOrNull` on a kind match is "the newest of that kind in this room" -- which is some other subgroup's. The screen for one child followed another child's progress. **The display was not the damage.** Both rungs disable their button while a signing of that kind is running and un-failed: ```kotlin enabled = … && (certificateSession == null || certificateFailed) ``` and a *completed* signing is un-failed forever. So once a parent had certified one subgroup, it could never be asked to certify another: the rung sat there disabled with no way out, and `proposeBirthCertificate` returned early on the same reading. The key state blocked the same way, and `createAdminGroup` is gated behind it, so the second subgroup could not be made at all. **It did not need two subgroups.** Adoption signs a key state in the child's own room (`proposeAcceptance`, `localChatRoom = childRoom`), so a group that has ever been adopted already holds a `GroupKeyStateEvent` session in its room -- enough to block the first subgroup it tries to make. What makes this a half-finished job rather than an oversight is that the *signed* facts were already right. `isCertified` goes through `observeBirthCertificate`, scoped by session id, and `isKeyStateSigned` through `observeSignedGroupKeyState`, whose own comment records this exact lesson being learned once already: "A ceremony room holds more than one ceremony the moment a subgroup's admins are the whole group." The in-flight lookups never got the same treatment, and the comment above the key-state watcher still asserted the premise that had stopped being true -- "the room a ceremony runs in signs nothing else". **The key state is matched on the ceremony the proposal names.** `GroupKeyStateEvent` carries `FrostKeyTag` from the moment the proposal is assembled, so `parseDkgSessionId(tags) == dkgSessionId` discriminates before the ceremony has a key and before anybody signs. The watcher moves out of `observeKeyState` -- deleted, with its `Job` -- and into `observeForCeremony`, which is already cancelled and re-pointed per ceremony and is where every other per-ceremony watcher lives. `observeForCeremony`'s null branch now clears `keyStateSession` too, which it could not before because the field was not its to clear. **The certificate is matched on the child it names, through a new `addresses`.** `certifies` could not be reused: it ends on `GroupKeyStateEvent.isSignedByRoom`, a real signature check by the parent's quorum, and the thing being matched here is a proposal that quorum has not signed yet -- that is what the rung is waiting for. `SubgroupBirthCertificateEvent.addresses` is the structural half (kind, content, the `d` tag, the parent tag), `certifies` now calls it so the two cannot drift, and its doc says outright that nothing may decide a lineage on it. The three values it reads come out of `assembleTags` together, so they agree by construction rather than by agreement. Correctness is not at stake in either: these two fields only decide which progress bar is drawn and which button is live. Whether the parent actually agreed is `isCertified`, which does check the signature. **`DkgRepository.roomDerivedBy(dkgSessionId)`** is the session-id-to-room derivation that `observeBirthCertificate` and `observeSignedGroupKeyState` each carried inline. Three callers now, one of them new, and the no-op repository answers null -- which is also the honest answer for a ceremony that has not finished, and the reason all three resolve it per emission rather than once. **Tests: `DkgRitualSecondSubgroupJvmTest`, 3 cases, each checked against the bug reinstated rather than only against the fix.** Two assert that the screen follows its own ceremony's signing when a sibling's is newer. The third is the permanent block, and it is driven in two emissions on a hot flow for a reason worth recording: written as a single emission it asserted "still null" and **passed against the bug**, because at that moment the watcher had not started. Nothing is proved by a field that is null before anybody has looked at it. **One test from the commit before this had to be rewritten, and the change is this commit's fault.** `DkgRitualStartOfferJvmTest`'s lost-update case forced its interleaving by parking the session watcher inside `pendingApproval` while the key-state watcher wrote. Those two are no longer concurrent -- the key-state watcher now starts *from* the session collector, after the park -- so the setup could not happen. The claim was untouched by that, so it is now made against the session watcher versus the room watcher, which are genuinely concurrent: both collect before either writes, neither waits on the other, and the field they contend over is `roomHasCeremony`, which is the one that decides whether the offer to start appears. Re-confirmed failing against the lost update reinstated. 973 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseDkgRepository.kt | 44 ++- .../subgroup/SubgroupBirthCertificateEvent.kt | 42 ++- .../compose/repository/DkgRepository.kt | 15 + .../ui/view/model/DkgRitualViewModel.kt | 126 ++++--- .../model/DkgRitualSecondSubgroupJvmTest.kt | 322 ++++++++++++++++++ .../view/model/DkgRitualStartOfferJvmTest.kt | 79 +++-- 6 files changed, 511 insertions(+), 117 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt 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 1353970d..fcb53e1f 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 @@ -110,26 +110,27 @@ class DatabaseDkgRepository( ): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) + /** + * By session id, not by room. A ceremony room holds more than one ceremony + * the moment a subgroup's admins are the whole group, and a lookup by room + * answered about whichever the room's rule picked -- for a room holding only + * a subgroup's ceremony, asking for the room's *own* found nothing at all, + * and a finished subgroup with a signed key state could never be created. + */ + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + database.dkgSessionDao() + .getSessionById(dkgSessionId) + ?.thresholdPublicKey + ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = database.groupSignedEventDao() .observeByKind(GroupKeyStateEvent.KIND) .map { signedEvents -> - // The room this ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - // - // By session id, not by room. A ceremony room holds more than one - // ceremony the moment a subgroup's admins are the whole group, and - // a lookup by room answered about whichever the room's rule picked - // -- for a room holding only a subgroup's ceremony, asking for the - // room's *own* found nothing at all, and a finished subgroup with - // a signed key state could never be created. - val subjectRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - - subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + roomDerivedBy(dkgSessionId) + ?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } } override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try { @@ -176,14 +177,9 @@ class DatabaseDkgRepository( database.groupSignedEventDao() .observeByKind(SubgroupBirthCertificateEvent.KIND) .map { signedEvents -> - // The room the ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - val subgroupChatRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - ?: return@map null + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null signedEvents .filter { 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 9c1f8155..0554151d 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 @@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent { subgroupChatRoomId: String, parentChatRoomId: String ): Boolean = runCatching { - if (event.kind != KIND) return false - - if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false - - val addressed = parseSubgroupChatRoomId(event.tags) ?: return false - if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false - - val named = parseParentChatRoomId(event.tags) ?: return false - if (!named.equals(parentChatRoomId, ignoreCase = true)) return false + if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false val path = parsePath(event.tags) ?: return false @@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent { GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId) }.getOrDefault(false) + /** + * Whether this event is *addressed to* that child by that parent -- the + * half of [certifies] that can be asked of a proposal nobody has signed. + * + * **Not a weaker [certifies].** It says who the event is about, not that + * the parent's quorum agreed to it: no derivation and no signature, so an + * event that merely claims the right pair passes. Nothing may decide a + * lineage on this. + * + * What it is for is telling one *in-flight* certificate signing from + * another in the same room. A parent certifies every subgroup it ever makes + * from one room, so the ceremony screen has several to choose between and + * no signature to choose by -- the proposal is what the quorum has not yet + * signed. The three values it reads are assembled together by + * [assembleTags], so they agree by construction. + */ + fun addresses( + event: Event, + subgroupChatRoomId: String, + parentChatRoomId: String + ): Boolean = runCatching { + if (event.kind != KIND) return false + + if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false + + val addressed = parseSubgroupChatRoomId(event.tags) ?: return false + if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false + + val named = parseParentChatRoomId(event.tags) ?: return false + named.equals(parentChatRoomId, ignoreCase = true) + }.getOrDefault(false) + /** * The name tag. A bare two-element tag with no class of its own, because * nothing but the proposal screen and the subgroup list ever reads it and 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 1e6c16d6..c61a9e85 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -65,6 +65,19 @@ interface DkgRepository { suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? + /** + * The id of the room a ceremony's key derives, or null while it has no key. + * + * The one name a ceremony and the room it makes have in common, and the only + * way to tell two of a parent's subgroups apart in anything written about + * the room rather than about the ceremony -- a birth certificate names the + * child, not the ceremony that produced it. + * + * Null is the ordinary answer rather than a failure: a ceremony has no key + * until it finishes, and every caller here is watching from before that. + */ + suspend fun roomDerivedBy(dkgSessionId: String): String? + /** * Opens a ritual in [localChatRoom], making this device the coordinator. * @@ -213,6 +226,8 @@ interface DkgRepository { override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null + override suspend fun roomDerivedBy(dkgSessionId: String): String? = null + override suspend fun proposeRitual( localChatRoom: LocalChatRoom, userPublicKey: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index feb4316f..5f6858ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -114,9 +114,6 @@ class DkgRitualViewModel( /** Watches the current session's messages; replaced whenever the session changes. */ private var messageObserver: Job? = null - /** Watches the room's signing sessions, which is where the key state is agreed. */ - private var keyStateObserver: Job? = null - /** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */ private var certificateObserver: Job? = null @@ -240,8 +237,6 @@ class DkgRitualViewModel( .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) .coerceIn(quorumRange()) - observeKeyState() - launch { roomSessions.collect { newest -> updateLoaded { current -> @@ -323,43 +318,6 @@ class DkgRitualViewModel( } } - /** - * Follows the group agreeing what its admin room will sign with. - * - * Nothing here watches a [GroupKeyState] row, because there is no room yet - * for one to belong to -- being able to follow this before the room exists - * is the point. What is watched instead is the session, for how the signing - * is going, and the signed event, for whether it finished. - * - * The room a ceremony runs in signs nothing else, so the newest session in - * it is the key state's -- but it is matched on the kind it carries anyway, - * so a room that one day signs something else does not confuse the two. - */ - private fun observeKeyState() { - keyStateObserver?.cancel() - keyStateObserver = viewModelScope.launch(Dispatchers.IO) { - // Two flows because they answer two questions off two tables. How - // the signing is going is the session row; whether it finished is - // the signed event, and only that second one can be asked at all - // before there is a room -- which is the whole point of asking it. - launch { - dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> - val keyState = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == GroupKeyStateEvent.KIND - } - }?.session - - updateLoaded { it.copy(keyStateSession = keyState) } - } - } - - - } - } - /** * Follows one ceremony: whether its key state has been signed, and -- for a * subgroup -- whether its parent has certified it. @@ -379,12 +337,50 @@ class DkgRitualViewModel( if (dkgSessionId == null) { updateLoaded { - it.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null) + it.copy( + isKeyStateSigned = false, + keyStateSession = null, + isCertified = false, + certificateSession = null + ) } return } certificateObserver = viewModelScope.launch(Dispatchers.IO) { + // How the agreement is going, as opposed to whether it finished -- + // two questions off two tables, and only the second can be asked + // before there is a room at all, which is the point of asking it. + // + // By the ceremony the proposal names, not by kind in this room. The + // room a ceremony runs in used to sign nothing else, and that is + // what this read assumed; a parent's Marmot room now hosts the + // ceremony of every subgroup the group makes, so it holds one of + // these per subgroup. Matching on kind alone handed the screen the + // newest of them -- which is some other subgroup's -- and with it a + // progress bar for a signing this ceremony is not in and a button + // disabled on the strength of it. Permanently, once that other one + // completed: the guard only reopens on a failure. + // + // `FrostKeyTag` is on the proposal from the moment it is made, so + // this works before the ceremony has a key and before anybody signs. + launch { + dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> + val keyState = sessions.firstOrNull { local -> + local.items.any { item -> + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + event.kind == GroupKeyStateEvent.KIND && + GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId + } + }?.session + + updateLoaded { it.copy(keyStateSession = keyState) } + } + } + // Whether the group has agreed what the room this ceremony's key // derives will sign with. Asked by ceremony rather than by room: the // room holds however many ceremonies it holds, and the answer is @@ -408,14 +404,43 @@ class DkgRitualViewModel( } launch { + // The child this ceremony is making, which is what a certificate + // names. Resolved per emission rather than once, because the + // ceremony has no key until it finishes and this flow is running + // before it does -- the same reason `observeBirthCertificate` + // resolves it per emission. + // + // Scoped to that child, not to the kind. A parent certifies every + // subgroup it ever makes from the same room, so by kind alone this + // found the newest certificate signing in the parent -- some other + // child's -- and read its progress as this one's. Worse, the rung + // disables "ask the group to certify" while a certificate session + // is running and un-failed, so once a parent had certified one + // subgroup it could never be asked to certify another. dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions -> - val certificate = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == SubgroupBirthCertificateEvent.KIND - } - }?.session + val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId) + + val certificate = subgroupChatRoomId?.let { child -> + sessions.firstOrNull { local -> + local.items.any { item -> + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + // `addresses`, not `certifies`: the thing being + // matched is a proposal, and `certifies` ends on + // a signature by the parent's quorum, which is + // precisely what has not happened yet. Whether + // the parent actually agreed is `isCertified`, + // which does check it. + SubgroupBirthCertificateEvent.addresses( + event = event, + subgroupChatRoomId = child, + parentChatRoomId = parentChatRoomId, + ) + } + }?.session + } updateLoaded { it.copy(certificateSession = certificate) } } @@ -736,7 +761,6 @@ class DkgRitualViewModel( override fun onCleared() { messageObserver?.cancel() - keyStateObserver?.cancel() certificateObserver?.cancel() super.onCleared() } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt new file mode 100644 index 00000000..84815488 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt @@ -0,0 +1,322 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A parent's room holds every one of its subgroups' signings, and the ceremony + * screen has to pick out the one it is showing. + * + * Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the + * rungs after the ceremony run there too: the birth certificate is the parent's + * signature, and the key state is signed over the parent's room because the + * subgroup's own room does not exist yet. So a parent making a second subgroup + * has two of each in one room, told apart by nothing but the ceremony they + * belong to. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualSecondSubgroupJvmTest { + private val parentChatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + // Real points, in the compressed form `SharedKeyDerivation` reads: it derives + // a room id off the curve, and a made-up 32 bytes is not on it. + private val firstKey = "02" + KeyPair().pubKey.toHexKey() + private val secondKey = "02" + KeyPair().pubKey.toHexKey() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + // Newest first, the way the query returns them. + certificateSigning("forSecond", subgroupOf(secondKey)), + certificateSigning("forFirst", subgroupOf(firstKey)), + ), + until = { it.certificateSession != null } + ) + + assertEquals( + "forFirst", + loaded.certificateSession?.id, + "the screen for one subgroup is following another subgroup's certificate" + ) + } + + @Test + fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + keyStateSigning("forSecond", dkgSessionId = "secondCeremony"), + keyStateSigning("forFirst", dkgSessionId = "firstCeremony"), + ), + until = { it.keyStateSession != null } + ) + + assertEquals( + "forFirst", + loaded.keyStateSession?.id, + "the screen for one ceremony is following another ceremony's key state" + ) + } + + /** + * The damaging half, and the reason this is a block rather than a cosmetic + * mix-up. Both rungs disable their button while a signing of that kind is + * running and un-failed, and a *completed* signing is un-failed forever -- + * so by kind alone, a parent that had certified one subgroup could never be + * asked to certify another, and the rung sat there disabled with no way out. + */ + @Test + fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking { + // Driven in two emissions rather than one, so the assertion is about a + // watcher that demonstrably ran. Asserting "still null" against a single + // emission passes before the watcher has started, which is how a version + // of this test passed against the bug it was written for. + val signingSessions = MutableStateFlow( + listOf( + certificateSigning("mine", subgroupOf(firstKey)), + keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"), + ) + ) + + val viewModel = viewModel(signingSessions) + viewModel.initiate() + + awaitLoaded(viewModel) { + it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState" + } + + // Now the room holds only a sibling's, both finished -- which is every + // parent that has already made one subgroup and is making its second. + signingSessions.value = listOf( + certificateSigning( + "siblingCertificate", + subgroupOf(secondKey), + stage = FrostSigningStage.COMPLETE + ), + // A key state naming another ceremony: the sibling's, or this parent's + // own from an adoption, which is signed in this room too. + keyStateSigning( + "siblingKeyState", + dkgSessionId = "secondCeremony", + stage = FrostSigningStage.COMPLETE + ), + ) + + val loaded = awaitLoaded(viewModel) { + it.certificateSession == null && it.keyStateSession == null + } + + // Both rungs gate on these being null or failed, and a completed signing + // is never failed -- so a sibling left in either of them shuts the rung + // for good. + assertNull(loaded.certificateSession) + assertNull(loaded.keyStateSession) + } + + private suspend fun load( + signingSessions: List, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded { + val viewModel = viewModel(MutableStateFlow(signingSessions)) + viewModel.initiate() + return awaitLoaded(viewModel, until) + } + + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + + /** The screen is always showing the FIRST subgroup's ceremony. */ + private fun viewModel( + signingSessions: MutableStateFlow> + ): DkgRitualViewModel { + val ceremony = DkgSession( + id = "firstCeremony", + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = DkgRitualStage.COMPLETE, + thresholdPublicKey = firstKey, + parentChatRoomId = parentChatRoomId, + subject = "Translators", + ) + + return DkgRitualViewModel( + chatRoomId = parentChatRoomId, + activeUserPublicKey = me, + dkgSessionId = ceremony.id, + initialDkgRitualUIState = DkgRitualUIState.Loading, + activeIdentityStateFlow = MutableStateFlow(null), + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null + + override fun observeSessionById(sessionId: String): Flow = + flowOf(ceremony) + + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flowOf(ceremony) + + override fun observeSigningSessions( + chatRoomId: String + ): Flow> = signingSessions + } + ) + } + + /** The room id a ceremony's key derives, which is what a certificate names. */ + private fun subgroupOf(thresholdPublicKey: HexKey): String = + press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private fun certificateSigning( + id: String, + subgroupChatRoomId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = firstKey, + adminPublicKeys = listOf(me, peer), + name = "Translators", + ), + content = subgroupChatRoomId, + sig = "ff".repeat(64), + ) + ) + + private fun keyStateSigning( + id: String, + dkgSessionId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = subgroupOf(firstKey), + dkgSessionId = dkgSessionId, + ), + content = firstKey, + sig = "ff".repeat(64), + ) + ) + + private fun signing( + id: String, + event: Event, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = LocalFrostSigningSession( + session = FrostSigningSession( + id = id, + stage = stage, + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + dkgSessionId = "firstCeremony", + threshold = 2, + participantCount = 2, + signerId = 0, + ), + unorderedItems = listOf( + FrostSigningItem( + sessionId = id, + itemIndex = 0, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = "55".repeat(32), + ) + ), + ) + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = parentChatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = "state", + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = parentChatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt index e82b816e..e811acb0 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt @@ -111,21 +111,30 @@ class DkgRitualStartOfferJvmTest { * * Every flow folding into this state used to read it, build a `copy` and * assign -- and the session watcher asks the database what this member owes - * in the middle of that. The key-state watcher writing during that pause had - * its change thrown away by the assignment that followed. The same pause in - * the other direction is what put `session` back to null and offered to - * start the ceremony on screen; this is the half of it that can be forced to - * happen rather than raced into. + * in the middle of that. Whoever wrote during that pause had their change + * thrown away by the assignment that followed. The same pause in the other + * direction is what put `session` back to null and offered to start the + * ceremony on screen; this is the half of it that can be forced to happen + * rather than raced into. + * + * The pair used here is the session watcher against the room watcher, which + * are the two that are genuinely concurrent: both are collecting before + * either has written, and neither waits on the other. `roomHasCeremony` is + * the field they fight over, and it is the one that decides the offer. */ @Test fun `a change written during a suspension survives the write that follows it`() = runBlocking { - val session = session(DkgRitualStage.COLLECTING_ROUND_1) - val signingSessions = MutableStateFlow>(emptyList()) + val live = session(DkgRitualStage.COLLECTING_ROUND_1) + val abandoned = session(DkgRitualStage.FAILED) - // Held open until the test has had the key-state watcher write. The - // seeding call runs before anything else is collecting, so it is let - // through; the session watcher's call is the one that waits. + val roomSessions = MutableStateFlow(live) + + // Opened once the test has had the room watcher write. The seeding call + // runs before anything else is collecting, so it is let through; the + // session watcher's call is the one that waits, and `parked` says when + // it is in there. val gate = CompletableDeferred() + val parked = CompletableDeferred() var calls = 0 val viewModel = viewModel( @@ -133,16 +142,16 @@ class DkgRitualStartOfferJvmTest { override fun observeLatestSessionForChatRoom( chatRoomId: String, parentChatRoomId: String?, - ): Flow = flowOf(session) + ): Flow = roomSessions - override fun observeSigningSessions( - chatRoomId: String - ): Flow> = signingSessions + override fun observeSessionById(sessionId: String): Flow = + flowOf(live) override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? { calls += 1 if (calls == 1) return null + parked.complete(Unit) gate.await() return DkgApprovalStep.ROUND_1 } @@ -151,33 +160,37 @@ class DkgRitualStartOfferJvmTest { viewModel.initiate() - withTimeout(5_000) { viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded } } + awaitLoaded(viewModel) { it.roomHasCeremony } - // The watcher is now parked inside `pendingApproval`, holding a copy of - // the state as it was before this line. - signingSessions.value = listOf(keyStateSigningSession()) - - withTimeout(5_000) { - viewModel.dkgRitualUIState.first { - (it as? DkgRitualUIState.Loaded)?.keyStateSession != null - } - } + // The session watcher is now inside `pendingApproval`, holding -- in the + // shape this used to be written in -- a copy of the state as it was + // before the next two lines. + withTimeout(5_000) { parked.await() } + roomSessions.value = abandoned + awaitLoaded(viewModel) { !it.roomHasCeremony } gate.complete(Unit) - val settled = withTimeout(5_000) { - viewModel.dkgRitualUIState.first { - (it as? DkgRitualUIState.Loaded)?.pendingApproval == DkgApprovalStep.ROUND_1 - } - } as DkgRitualUIState.Loaded + val settled = awaitLoaded(viewModel) { + it.pendingApproval == DkgApprovalStep.ROUND_1 + } - assertNotNull( - settled.keyStateSession, - "the session watcher wrote back a copy taken before the key state arrived" + assertFalse( + settled.roomHasCeremony, + "the session watcher wrote back a copy taken before the room watcher's change" ) - assertEquals(session.id, settled.session?.id) + assertEquals(live.id, settled.session?.id) } + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel( chatRoomId = chatRoomId, activeUserPublicKey = me, From 306b8bb150377d4d041786c7337e80d7b218d705 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:20:12 +0200 Subject: [PATCH 24/32] test(chapters): the five gaps a coverage pass found, and two vacuous tests A pass counting tests per file rather than per feature found five pieces of this work with none. One of them mattered. MAX_PROPOSAL_BYTES was enforced and never tested, on either side. The inbound check is the half that counts -- the proposal is the one place a remote party decides how much work everybody else does -- and it had nothing at all. Four tests now, propose and accept, bytes and count, in SignedGroupKeyStateTest where the adversarial inbound tests belong. The count cap's accept side was untested too, although the propose-side test's own comment already claimed otherwise. Two of those tests passed vacuously as first written, which is the part worth keeping. acceptProposal rebuilds every event from its own fields under the room's path and drops the whole proposal if an id does not match, so a hand-built event with an invented id is refused whatever its size -- and an assertNull then stays green with the guard deleted. authoredProposal fixes it by taking its events from real proposals, so their ids are the ones the manager computes, and a control asserts that a well-formed proposal of the same shape does open a session. That control failed on its first run, which is how the vacuity was found; commenting the guard out now fails exactly the test named after it. UploadChaptersScreen had no UI test, against a project with fifteen runDesktopComposeUiTest files that tests other screens this way. Ten now: the cost of an upload stated before the picker opens, the index and name per row, every per-row reason rendering, and the FAB counting only what it would actually propose rather than how many files were picked. canPropose, kibibytes and the chapter summary had none either, each being read only by a composable. Eleven, five and five. Still open, and not closable here: the three FilePicker actuals need instrumentation this project does not have, and on iOS a host that can compile them. 1030 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/text/KibibytesTest.kt | 50 ++++ .../mantra/compose/text/ProposedEventTest.kt | 106 ++++++++ .../ui/view/state/UploadChaptersStateTest.kt | 167 ++++++++++++ .../managers/SignedGroupKeyStateTest.kt | 163 ++++++++++++ .../composable/UploadChaptersScreenJvmTest.kt | 247 ++++++++++++++++++ docs/chapter-uploads.md | 33 ++- 6 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/KibibytesTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreenJvmTest.kt diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/KibibytesTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/KibibytesTest.kt new file mode 100644 index 00000000..195b9c1f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/KibibytesTest.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The figure in "19.9 KiB", which two screens show next to each other. + * + * Whole kibibytes would print a 20,473-byte chapter and its 21,226-byte limit + * as the same "19 KiB", which reads as a form refusing something it says is + * within the limit. And truncating rather than rounding is the whole point: a + * figure shown as under the limit has to be under it. + */ +class KibibytesTest { + + @Test + fun `whole kibibytes`() { + assertEquals("0.0", kibibytes(0)) + assertEquals("1.0", kibibytes(1024)) + assertEquals("64.0", kibibytes(64 * 1024)) + } + + @Test + fun `one decimal place`() { + assertEquals("1.5", kibibytes(1024 + 512)) + assertEquals("19.9", kibibytes(20473)) + assertEquals("20.7", kibibytes(21226)) + } + + @Test + fun `two figures a form shows together do not collapse into one`() { + // The reason this is not whole kibibytes. Both of these round to 19. + assertEquals(true, kibibytes(20473) != kibibytes(21226)) + } + + @Test + fun `it truncates rather than rounds`() { + // 1023 bytes is 0.999 KiB. Rounding would print "1.0", which is a figure + // over the value it describes -- and when the value is being compared to + // a cap, that is the direction that lies. + assertEquals("0.9", kibibytes(1023)) + assertEquals("63.9", kibibytes(64 * 1024 - 1)) + } + + @Test + fun `under a tenth of a kibibyte reads as zero point zero`() { + // Honest rather than pretty: a 50-byte chapter is not 0.1 KiB. + assertEquals("0.0", kibibytes(50)) + } +} 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 9ecddf7e..c8215240 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -3,6 +3,9 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent /** * How an event a group signs is said to the member signing it. @@ -23,6 +26,109 @@ class ProposedEventTest { assertEquals("An article", summary.detail) } + /** + * A chapter is the longest thing a group is asked to sign, and the one whose + * summary cannot be the thing itself. + * + * So the summary stands in for it: the name, how many words, and how many + * chunks came out of the text -- which is the rest of what the batch + * contains and so the rest of what is being agreed to. + */ + @Test + fun `a chapter is named, measured, and counted`() { + val summary = ProposedEvent.summarize(chapter(name = "The wreck"), chunkCount = 4) + + assertEquals("New chapter", summary.label) + assertEquals("The wreck · 12 words · 4 chunks", summary.detail) + } + + @Test + fun `one chunk is said in the singular`() { + val summary = ProposedEvent.summarize(chapter(), chunkCount = 1) + + assertTrue(summary.detail.endsWith("1 chunk"), summary.detail) + } + + /** + * The count comes from the batch, not from the text. + * + * It used to be derived by re-splitting the chapter's own markdown, which is + * a count of the batch dressed as a fact about the text: across a splitter + * change the figure a member reads would disagree with the events they are + * approving, because the batch was built by the proposer's splitter and + * counted by the reader's. This asserts the summary reports what it was + * 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`() { + val summary = ProposedEvent.summarize( + chapter(originalText = "One.\n\nTwo."), + chunkCount = 5, + ) + + assertTrue(summary.detail.endsWith("5 chunks"), summary.detail) + } + + @Test + 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")) + + assertEquals("The wreck · 12 words", summary.detail) + } + + @Test + 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( + ChunkEvent.build( + chapterId = "d".repeat(64), + text = "It began badly.", + index = 2, + wordCount = 3, + characterCount = 15, + ).let { template -> + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + } + ) + + assertTrue(summary.detail.contains("It began badly."), summary.detail) + } + + /** A chapter as the group would be asked to sign it. */ + private fun chapter( + name: String = "The wreck", + originalText: String = "One two three.\n\nFour five six.\n\nSeven eight nine.\n\nTen eleven twelve.", + ): Event { + val template = ChapterEvent.build( + artifactVersionId = "e".repeat(64), + name = name, + originalText = originalText, + index = 0, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ) + return Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + } + private fun event( kind: Int, tags: Array> = emptyArray(), 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 new file mode 100644 index 00000000..bafb8d18 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/UploadChaptersStateTest.kt @@ -0,0 +1,167 @@ +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.ChatRoom +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +/** + * What the upload screen's button is allowed to offer. + * + * `canPropose` is read only by the FAB, which is exactly why it is worth a test + * of its own: there is no other caller to notice if one of its four conditions + * goes missing, and each of them exists because offering the action without it + * would ask the group for something that cannot happen. + */ +class UploadChaptersStateTest { + + private val room = LocalChatRoom( + chatRoom = ChatRoom( + id = "room", + userPublicKey = "", + subject = "#Ekklesia", + description = "", + mlsGroupState = null, + ) + ) + + private val artifact = MantraArtifact( + id = "artifactId", + publicKey = "author", + name = "To Kill a Mockingbird", + url = "https://example.test/bird", + visibility = "private", + dialectId = "dialectId", + license = "cc", + chatRoomId = "room", + signature = "", + ) + + private val version = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "room", + signature = "", + ) + + private fun row( + id: String, + problem: UploadProblem? = null, + sessionId: String? = null, + ) = PickedChapter( + id = id, + fileName = "$id.md", + name = id, + markdown = "A paragraph.", + chunks = listOf("A paragraph."), + wordCount = 2, + characterCount = 12, + proposalBytes = 1000, + problem = problem, + sessionId = sessionId, + ) + + private fun state( + chapters: List = listOf(row("one")), + canSign: Boolean = true, + artifactVersion: MantraArtifactVersion? = version, + isProposing: Boolean = false, + ) = UploadChaptersUIState.Loaded( + localChatRoom = room, + artifact = artifact, + artifactVersion = artifactVersion, + canSign = canSign, + chapters = chapters, + isProposing = isProposing, + ) + + @Test + fun `a clean row with a version and a key can be proposed`() { + assertTrue(state().canPropose) + } + + @Test + fun `nothing picked is nothing to propose`() { + assertFalse(state(chapters = emptyList()).canPropose) + } + + @Test + fun `a group with no shared key cannot sign a chapter into existence`() { + assertFalse(state(canSign = false).canPropose) + } + + @Test + fun `an artifact with no version has nothing for a chapter to hang off`() { + assertFalse(state(artifactVersion = null).canPropose) + } + + @Test + fun `a pass already running is not offered a second time`() { + // The guard against a double tap proposing every chapter twice, which + // nothing could retract. + assertFalse(state(isProposing = true).canPropose) + } + + @Test + fun `a blocking problem takes its own row out and leaves the others`() { + val chapters = listOf( + row("good"), + row("binary", problem = UploadProblem.NotText), + row("also-good"), + ) + + assertEquals(listOf("good", "also-good"), state(chapters).proposable.map { it.id }) + assertTrue(state(chapters).canPropose) + } + + @Test + fun `every row blocked is nothing to propose`() { + val chapters = listOf( + row("binary", problem = UploadProblem.NotText), + row("empty", problem = UploadProblem.Empty), + ) + + assertEquals(emptyList(), state(chapters).proposable) + assertFalse(state(chapters).canPropose) + } + + @Test + fun `a warning is not a block`() { + // A book can repeat a page, so a duplicate is said and not refused. + val chapters = listOf(row("copy", problem = UploadProblem.Duplicate("The Wreck"))) + + assertEquals(listOf("copy"), state(chapters).proposable.map { it.id }) + assertTrue(state(chapters).canPropose) + } + + @Test + 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 }) + assertTrue(state(chapters).canPropose) + } + + @Test + fun `a row already proposed is neither proposable nor forgotten`() { + val chapters = listOf(row("sent", sessionId = "session"), row("waiting")) + val loaded = state(chapters) + + assertEquals(listOf("waiting"), loaded.proposable.map { it.id }) + assertEquals(listOf("sent"), loaded.proposed.map { it.id }) + } + + @Test + fun `everything proposed leaves nothing to offer`() { + val chapters = listOf(row("sent", sessionId = "session")) + + assertEquals(emptyList(), state(chapters).proposable) + assertFalse(state(chapters).canPropose) + } +} 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 05c24f91..8c09e3f0 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -1207,6 +1207,169 @@ class SignedGroupKeyStateTest { assertEquals(before.map { it.nonceRandom }, other.items(session.id).map { it.nonceRandom }) } + /** + * The byte cap, on both sides, for the same reason the count cap has both. + * + * `MAX_BATCH_SIZE` bounds how many events a batch holds; `MAX_PROPOSAL_BYTES` + * bounds how large they are, and a batch can pass the first and fail the + * second -- sixty-four dialects are a few kilobytes and one chapter is + * eighty. Before the cap existed the failure was silent: the proposal went + * out, the group event was over size, and the session stalled with nothing + * to blame. + */ + @Test + fun `a proposal over the byte cap is refused when proposed`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + + // One event, so the count cap cannot be what refuses this. + assertFailsWith { + FrostSigningManager.proposeSigning( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + kind = 1, + tags = emptyArray(), + content = "w".repeat(FrostSigningManager.MAX_PROPOSAL_BYTES + 1) + ) + } + + assertEquals( + emptyList(), + creator.db.frostSigningSessionDao().getSessionsForChatRoom(adminRoomId), + "a refused proposal must leave no session behind" + ) + } + + /** + * And dropped on arrival, which is the half that matters. + * + * The proposal is the one place a remote party decides how much work + * everybody else does. A proposer who could hand every member an arbitrarily + * large batch would have them do unbounded native work and publish an + * unbounded group event from a single message. + * + * **The payload has to be valid in every other way**, or this passes for the + * wrong reason. `acceptProposal` rebuilds each event from its own fields + * under the room's path and drops the whole proposal if any id does not + * match what it claims -- so a hand-built event with an invented id is + * refused whatever its size, and a test using one would stay green with the + * cap deleted. That is how this was first written and what + * [authoredProposal] exists to fix: the events come from real proposals, so + * their ids are the ones the manager computes. + */ + @Test + fun `a proposal over the byte cap is dropped on arrival`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + val sessionId = "f1".repeat(32) + + // Two events, each legitimately proposed at a third of the cap, so + // together they are over it and apart they are unremarkable. + val third = FrostSigningManager.MAX_PROPOSAL_BYTES / 3 + val oversize = authoredProposal(creator, "a".repeat(third), "b".repeat(third), "c".repeat(third)) + assertTrue( + FrostSigningEvents.encodeProposal(oversize).encodeToByteArray().size > + FrostSigningManager.MAX_PROPOSAL_BYTES, + "the fixture must actually exceed the cap" + ) + + deliverProposal(other, sessionId, oversize) + + assertNull(other.session(sessionId), "an over-size proposal must not open a session") + } + + /** + * The count cap on arrival too, which this file claimed and did not check. + * + * The propose-side test above says in its own comment that the cap "is + * checked on arrival and not only when proposing" -- which was true of the + * code and untrue of the tests. + */ + @Test + fun `a batch over the count cap is dropped on arrival`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + val sessionId = "f2".repeat(32) + + // Small events, so the byte cap cannot be what refuses this. + val tooMany = authoredProposal( + creator, + *(0..FrostSigningManager.MAX_BATCH_SIZE).map { "event $it" }.toTypedArray() + ) + assertEquals(FrostSigningManager.MAX_BATCH_SIZE + 1, tooMany.size) + + deliverProposal(other, sessionId, tooMany) + + assertNull( + other.session(sessionId), + "a proposal of more events than the cap must not open a session" + ) + } + + /** + * The control for the two tests above. + * + * Both assert that no session is opened, and `acceptProposal` has four other + * reasons to open none -- an unknown key, a ceremony this device was not in, + * an unparseable payload, an id that does not match its fields. Without + * this, either could pass for a reason that has nothing to do with the cap + * it is named after, and the guard could be deleted with both still green. + * + * Which is not hypothetical: written with hand-built ids, both passed and + * this failed. + */ + @Test + fun `a well-formed proposal of the same shape does open a session`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + val sessionId = "f3".repeat(32) + + deliverProposal(other, sessionId, authoredProposal(creator, "a dialect")) + + assertNotNull( + other.session(sessionId), + "a proposal that breaks no cap must open a session, or the cap tests prove nothing" + ) + } + + /** + * The unsigned events a real proposal produced, ids and all. + * + * Taken from the manager rather than built here, because the id is a hash + * over the group's key at the room's derivation path and the receiving + * device recomputes it. Anything else is refused before a cap is consulted. + */ + private suspend fun authoredProposal(device: Device, vararg contents: String): List = + contents.flatMap { content -> + val session = FrostSigningManager.proposeSigning( + database = device.db, + localChatRoom = device.room, + userPublicKey = device.publicKey, + kind = 1, + tags = emptyArray(), + content = content + ) + device.items(session.id).map { Event.fromJson(it.unsignedEventJson) } + } + + /** Hands [device] a proposal under [sessionId], as a dishonest coordinator would. */ + private suspend fun deliverProposal(device: Device, sessionId: String, events: List) { + FrostSigningManager.processSigningPayload( + database = device.db, + localChatRoom = device.room, + innerEvent = Event( + id = "4".repeat(64), + pubKey = members[0], + createdAt = 1, + kind = FrostSigningEvents.PROPOSAL, + tags = FrostSigningEvents.assembleTags(sessionId, dkgSessionId = ceremonyId), + content = FrostSigningEvents.encodeProposal(events), + sig = "" + ), + userPublicKey = device.publicKey + ) + } + /** Three dialects, distinct enough that an order bug shows up as a wrong name. */ private fun dialects() = listOf( Triple("Sepedi", "ZA", "nso"), diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreenJvmTest.kt new file mode 100644 index 00000000..ac2a8411 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/UploadChaptersScreenJvmTest.kt @@ -0,0 +1,247 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import kotlin.test.Test +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.PickedChapter +import press.mantra.compose.ui.view.state.UploadChaptersUIState +import press.mantra.compose.ui.view.state.UploadProblem + +/** + * What the upload screen says before anything has been proposed. + * + * The screen is a review step, so its whole job is to be read: the cost of the + * upload in front of the picker, a row per file saying where it lands and what + * is wrong with it, and a button that offers only what can actually be + * proposed. None of that is checkable from the view model -- `canPropose` is + * read by the FAB and the per-row reasons are rendered from a `when` -- so it is + * checked here, against a real composition. + */ +@OptIn(ExperimentalTestApi::class) +class UploadChaptersScreenJvmTest { + + private val room = LocalChatRoom( + chatRoom = ChatRoom( + id = "room", + userPublicKey = "", + subject = "#Ekklesia", + description = "", + mlsGroupState = null, + ) + ) + + private val artifact = MantraArtifact( + id = "artifactId", + publicKey = "author", + name = "To Kill a Mockingbird", + url = "https://example.test/bird", + visibility = "private", + dialectId = "dialectId", + license = "cc", + chatRoomId = "room", + signature = "", + ) + + private val version = MantraArtifactVersion( + id = "artifactVersionId", + artifactId = "artifactId", + publicKey = "author", + versionLabel = "1.0", + chatRoomId = "room", + signature = "", + ) + + private fun row( + id: String, + name: String = id, + problem: UploadProblem? = null, + sessionId: String? = null, + ) = PickedChapter( + id = id, + fileName = "$id.md", + name = name, + markdown = "A paragraph.", + chunks = listOf("A paragraph."), + wordCount = 2, + characterCount = 12, + proposalBytes = 1000, + problem = problem, + sessionId = sessionId, + ) + + @Test + fun `before any file is picked, the cost of an upload is already on screen`() = render( + state(chapters = emptyList()) + ) { + // Said before the picker opens, not after: an upload spends the room's + // attention and the person picking files is the only one who can weigh + // it. + onNodeWithText("Every file becomes its own proposal", substring = true).assertIsDisplayed() + onNodeWithText("At most 20 files in one upload.").assertIsDisplayed() + onNodeWithText("No files picked yet.").assertIsDisplayed() + onNodeWithText("Choose files").assertIsDisplayed() + } + + @Test + fun `a picked file shows where it lands and what it will be called`() = render( + state(chapters = listOf(row("the-wreck", name = "The wreck")), baseIndex = 3) + ) { + onNodeWithText("the-wreck.md").assertIsDisplayed() + // Numbered from the version's own next index, not from one. + onNodeWithText("Chapter 3 of the version").assertIsDisplayed() + onNodeWithText("The wreck").assertIsDisplayed() + } + + @Test + fun `the button counts what it would actually propose`() = render( + state( + chapters = listOf( + row("good"), + row("binary", problem = UploadProblem.NotText), + row("also-good"), + ) + ) + ) { + // Two, not three: a file that is not text was never offered. + onNodeWithText("Propose 2 chapters").assertIsEnabled() + onNodeWithText("Not text. A chapter is markdown or plain text.").assertIsDisplayed() + } + + @Test + fun `a group with no shared key is told, and offered nothing`() = render( + state(canSign = false) + ) { + onNodeWithText("This group has no shared key", substring = true).assertIsDisplayed() + onNodeWithText("Propose 1 chapters").assertIsNotEnabled() + } + + @Test + fun `an artifact with no version is told, and offered nothing`() = render( + state(artifactVersion = null) + ) { + onNodeWithText("This artifact has no version", substring = true).assertIsDisplayed() + onNodeWithText("Propose 1 chapters").assertIsNotEnabled() + } + + @Test + fun `every file blocked leaves the button offering nothing`() = render( + state( + chapters = listOf( + row("binary", problem = UploadProblem.NotText), + row("blank", problem = UploadProblem.Empty), + ) + ) + ) { + onNodeWithText("Propose 0 chapters").assertIsNotEnabled() + onNodeWithText("Nothing in it to translate.").assertIsDisplayed() + } + + @Test + fun `a duplicate is said without being refused`() = render( + state(chapters = listOf(row("copy", problem = UploadProblem.Duplicate("The wreck")))) + ) { + onNodeWithText("The same text as The wreck.", substring = true).assertIsDisplayed() + // A book can repeat a page, so it is a warning and the button still works. + onNodeWithText("Propose 1 chapters").assertIsEnabled() + } + + @Test + fun `a chapter too long for one session says how to fix it`() = render( + state(chapters = listOf(row("long", problem = UploadProblem.TooManyChunks(80)))) + ) { + onNodeWithText("80 chunks", substring = true).assertIsDisplayed() + onNodeWithText("Split this file.", substring = true).assertIsDisplayed() + } + + @Test + fun `a proposed row waits, keeps its name, and cannot be removed`() = render( + state(chapters = listOf(row("sent", sessionId = "session"), row("waiting"))) + ) { + onNodeWithText("Waiting for the group to sign").assertIsDisplayed() + // Its chapter is in front of the group: nothing about it is still a + // decision, so there is no way to take it out of the list. + onNodeWithContentDescription("Waiting for the group to sign").assertIsDisplayed() + + // And the button becomes a retry for what is left. + onNodeWithText("Propose the remaining 1").assertIsEnabled() + } + + @Test + fun `a failed row is listed with its reason and can be tried again`() = render( + state( + chapters = listOf( + row("sent", sessionId = "session"), + row("failed", problem = UploadProblem.ProposalFailed), + ) + ) + ) { + onNodeWithText("Proposing this one failed. Try again.").assertIsDisplayed() + // The only place a retry can be aimed from. + onNodeWithText("Propose the remaining 1").assertIsEnabled() + } + + private fun state( + chapters: List = listOf(row("one")), + canSign: Boolean = true, + artifactVersion: MantraArtifactVersion? = version, + baseIndex: Int = 0, + ) = UploadChaptersUIState.Loaded( + localChatRoom = room, + artifact = artifact, + artifactVersion = artifactVersion, + canSign = canSign, + chapters = chapters, + baseIndex = baseIndex, + ) + + /** + * Tall on purpose: the rows are cards in a `LazyColumn` and an assertion + * about one that has not been scrolled to is an assertion about nothing. + */ + private fun render( + uiState: UploadChaptersUIState, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 420, height = 2400) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + UploadChaptersScreen( + activeUserPublicKey = "a".repeat(64), + artifactId = "artifactId", + chatRoomId = "room", + relayHint = null, + initialUIState = uiState, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/docs/chapter-uploads.md b/docs/chapter-uploads.md index 8b6471f2..f69bd0b0 100644 --- a/docs/chapter-uploads.md +++ b/docs/chapter-uploads.md @@ -987,7 +987,38 @@ and stays retryable, and a second pass after the key arrives proposes all three numbered from zero. The failed pass spent no indices, because it created nothing. -Total: 995 tests, and the counts per phase are in each commit. +Total: 1030 tests, and the counts per phase are in each commit. + +### The five gaps a coverage pass found afterwards + +Counting tests per file rather than per feature turned up five things with none, +and one of them mattered: + +- **`MAX_PROPOSAL_BYTES` was enforced and never tested, on either side.** The + inbound check is the half that counts — the proposal is where a remote party + decides how much work everybody else does — and it had nothing. Now four + tests, propose and accept, bytes and count, in `SignedGroupKeyStateTest` where + the adversarial inbound tests live. The count cap's accept side was missing + too, although the propose-side test's own comment already claimed it. +- **Two of those tests passed vacuously when first written**, which is the part + worth remembering. `acceptProposal` rebuilds every event under the room's path + and drops the proposal if an id does not match its fields, so a hand-built + event with an invented id is refused whatever its size — and an `assertNull` + then passes with the cap deleted. The fix is `authoredProposal`, which takes + its events from real proposals so the ids are the ones the manager computes, + plus a control asserting that a well-formed proposal of the same shape *does* + open a session. The control failed on the first run, which is how this was + found; commenting the guard out now fails exactly the test named after it. +- **`UploadChaptersScreen` had no UI test**, against a project that has fifteen + `runDesktopComposeUiTest` files and tests other screens this way. Ten now: the + cost stated before the picker, the index and name per row, every per-row + reason rendering, and the FAB counting only what it would actually propose. +- **`canPropose`, `kibibytes` and the chapter summary** had none either, being + read only by composables. Eleven, five and five. + +The gap that stays open is the three `FilePicker` actuals, which need +instrumentation this project does not have — and on iOS a host that can compile +them. Rollout needs no flag. The chapter and chunk kinds, their tags and their apply branches are unchanged, so an older build receives an uploaded chapter as the From b6c3e3ddc0073c24d7c958f941d8128318e7e572 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:22:17 +0200 Subject: [PATCH 25/32] test(subgroups): pin what `addresses` is worth, and when one ceremony is the whole question Two surfaces the last three commits added and left resting on indirect coverage. Both are decisions rather than plumbing, and neither had a test that would notice it changing. **`SubgroupBirthCertificateEvent.addresses` had none at all.** Its sibling `certifies` has nineteen, several of them real FROST forgeries, because a certificate is the whole of what a subgroup relationship is. `addresses` was then added *beside* it as a deliberately weaker reading -- no derivation, no signature -- and exercised only through a view model, which is the wrong place to learn what it is worth. The invariant worth having is the one a future reader can get wrong: **an event can pass `addresses` and fail `certifies`.** Two cases say so in the two ways it happens -- an unsigned proposal, which is exactly what the ceremony screen holds and asks about, and a forgery authored and signed by the child's own quorum, which is anybody at all. A third asserts `certifies` implies `addresses`, so the weaker one cannot drift into being the stricter one while both are called "which child is this about". The other two are the case the predicate exists for: two subgroups of one parent. A sibling's well-formed proposal is not addressed to this child, *and is* addressed to the sibling -- told apart rather than both refused, which a predicate that merely failed closed would also satisfy. Checked by giving `addresses` a signature check: the two "and certifies nothing" cases fail, the other three do not. **`hidesOtherCeremonies` had none either**, and it decides where a tap in the transcript goes. `CeremonyQueueRoutingJvmTest` pins both of its guards separately, which is the point of four cases rather than two: - one ceremony owed -> open it. Drop the `size > 1` and this fails. - two owed -> the list, from either tap, because neither is the whole of what the room is waiting on this member for. - a *finished* ceremony's line -> open it, even while two others are owed. This is the restraint, and dropping the "is itself awaiting" clause is what it catches: a reader tapping history is asking to see history, not to be handed somebody else's queue. - an abandoned one is never owed, even when `pendingApproval` says it is. Each regression was reinstated separately and each is caught by exactly one case. One thing the empty expectation needed care over, and it is the same trap a test in `26d305bc` fell into: a watcher that writes once cannot be waited for by its own empty value, because empty is indistinguishable from not having run. The abandoned case waits on a clock instead and says so. **`hidesOtherDecisions`, the proposal counterpart this mirrors, still has no test.** It predates all of this and is left alone, but the asymmetry now runs the other way and is worth knowing about. **Still untested, and deliberately so rather than overlooked:** the three entry points into the ceremony list -- the "Key ceremonies" row on the group's details, which exists to be reachable in a *Marmot* room where "Shared key" is not, the standing notice handing back null for several, and the transcript line's routing. Those are desktop compose-ui tests over `ChatRoomDetailScreen` and `ChatTranscript`, both of which stand up considerably more than anything here does. 982 jvm tests, 459 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../SubgroupBirthCertificateEventTest.kt | 146 ++++++++++++ .../view/model/CeremonyQueueRoutingJvmTest.kt | 216 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt 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 a625bb39..ada04fb2 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 @@ -436,6 +436,152 @@ class SubgroupBirthCertificateEventTest { assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse)) } + // ---- `addresses`: the half that can be asked of a proposal ------------ + // + // The ceremony screen has to tell one *in-flight* certificate signing from + // another in the parent's room, and a parent certifies every subgroup it + // ever makes from that one room. `certifies` cannot answer it: it ends on a + // signature by the parent's quorum, which is exactly what the screen is + // waiting for. `addresses` is the structural half, and the only thing worth + // asserting about it is where it stops. + + @Test + 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. + val proposal = certificate(signature = "0".repeat(128)) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "the screen could not tell which subgroup its own proposal is about" + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "an unsigned proposal is being read as a parent's certificate" + ) + } + + @Test + 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 + // deciding which of its own room's signings to draw does. + val forged = certificate(author = childMaterial) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a sibling's proposal is not addressed to this child`() { + // Two subgroups of one parent, which is the case the screen exists to + // tell apart. The sibling's proposal is a real, well-formed certificate + // request; it is simply about somebody else. + val siblingRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path) + + val sibling = certificate( + content = siblingRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = "Another team", + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "one subgroup's rung is following a sibling's proposal" + ) + + // And it is addressed to the sibling, so the sibling's own screen finds + // it -- the two are told apart rather than both being refused. + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a proposal naming another parent is not addressed by this one`() { + val fromElsewhere = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path), + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = fromElsewhere, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + 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". + val valid = certificate() + + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt new file mode 100644 index 00000000..f6c876cb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt @@ -0,0 +1,216 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.delay +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ChatMessageListUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * When tapping one ceremony in the transcript is answering the whole question, + * and when it is answering a third of it. + * + * The transcript names one ceremony per line and its standing notice stands for + * however many are owed. Opening the named one is right while it is the only + * thing being asked; it stops being right the moment there are two, because the + * second is not on screen beside the first -- it may be pages up, or have + * arrived while the member was reading -- so answering the one they tapped and + * leaving would look exactly like being done. + * + * `hidesOtherDecisions` has made this call for proposals since before subgroups + * existed. [ChatMessageListViewModel.hidesOtherCeremonies] is its counterpart, + * and a room holds several ceremonies as a matter of course now that a + * subgroup's ChillDKG runs in its parent's room. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CeremonyQueueRoutingJvmTest { + private val chatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `one ceremony owed is answered by opening it`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("only", DkgRitualStage.COLLECTING_ROUND_1)), + pending = mapOf("only" to DkgApprovalStep.ROUND_1), + expected = 1 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("only"), + "the one ceremony owed was sent to a queue of itself" + ) + } + + @Test + fun `two ceremonies owed send the member to the list instead`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS) + .copy(parentChatRoomId = chatRoomId, subject = "Translators"), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + // Either tap: the queue is the honest answer to both, because neither is + // the whole of what the room is waiting on this member for. + assertTrue(viewModel.hidesOtherCeremonies("ours")) + assertTrue(viewModel.hidesOtherCeremonies("subgroup")) + } + + /** + * The restraint, and the reason this is not just "does the room owe two". + * A line about a ceremony the group finished is history, and a reader who + * taps one is asking to see that -- not to be handed a queue of other + * people's business. + */ + @Test + fun `a finished ceremony is opened on its own even while others are owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("done", DkgRitualStage.COMPLETE), + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("done"), + "a finished ceremony's line was answered with somebody else's queue" + ) + } + + /** + * An abandoned one is not owed either, which matters because the room's + * watcher filters on it and a row that slipped through would put a dead + * ceremony at the top of the notice. + */ + @Test + fun `an abandoned ceremony is never counted as owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("abandoned", DkgRitualStage.FAILED)), + // Says yes, and has to be ignored on a failure. + pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1), + expected = 0 + ) + + assertTrue(viewModel.ceremoniesAwaitingYou.isEmpty()) + assertFalse(viewModel.hidesOtherCeremonies("abandoned")) + } + + /** Builds the view model and waits until its watcher has settled on [expected]. */ + private suspend fun awaiting( + sessions: List, + pending: Map, + expected: Int, + ): ChatMessageListViewModel { + val viewModel = ChatMessageListViewModel( + initialChatMessageListUIState = ChatMessageListUIState.Loading, + localChatRoom = room(), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeSessionsForChatRoom( + chatRoomId: String + ): Flow> = flowOf(sessions) + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = + pending[session.id] + } + ) + + viewModel.initiate() + + // The watcher writes once, so an empty expectation cannot be waited for + // by its own value -- it is indistinguishable from not having run. Wait + // for a non-empty one, and give an empty one a moment to be wrong in. + withTimeout(5_000) { + if (expected == 0) { + delay(200) + } else { + while (viewModel.ceremoniesAwaitingYou.size != expected) delay(5) + } + } + + assertEquals(expected, viewModel.ceremoniesAwaitingYou.size) + return viewModel + } + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) + + private fun session(id: String, stage: DkgRitualStage) = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = peer, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = peer, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) +} From 93f8ab313cf910312916c7e25abe081fdf03f467 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:26:05 +0200 Subject: [PATCH 26/32] test(projects): cover the three view models and the query nothing asked about The feature's logic was tested from the events down to the chronicle and not at all above the repository. Four gaps, each one something no other layer would catch. **AddProjectViewModel.** A project and its contents go out as one batch whose memberships carry the project's id -- which is a hash over the group's key at the room's path and does not exist until the proposal is made, so the screen hands over a function of the signed lead. Nothing downstream notices if that function returns the wrong thing: a membership naming another project is a valid event, signs cleanly, and lands as a row pointing elsewhere. Eight cases assert the batch's shape against a recording repository that signs the lead the way a finished session does, including that the creator is the member filling the form in -- there is no field for it and nothing else to check it against. **AddToProjectViewModel.** The candidate filtering is the only thing stopping a duplicate membership, because a duplicate is a valid event with its own id: the keys are satisfied, the signature is good, the row gets written. A wrong filter would spend a quorum saying what the group has already said and look like the feature working. Ten cases, including that the two halves filter independently. **FrostSigningViewModel's resolver.** Unresolved, the signing screen asks a member to agree to "a4f1be55d0c3". Seven cases through the same `summarize` call the screen makes: a membership reads as the artifact and label, a stranger falls back to a hash rather than an invented name, the room owning its own work says nothing, and a project being signed in the same batch resolves to nothing -- which is why a membership says the version first. **getProjectsByTranslationArtifactVersionId** had no test at any layer while its artifact-side twin had one from the start. That asymmetry is what a copied join hides. Two notes for whoever writes the next view-model test. These view models hold state in `mutableStateOf` and initiate on `Dispatchers.IO`, so there is nothing to collect and the call returns before the work is done -- hence `awaitValue`, polling in real time for the same reason `NavigationIdentityRoutingJvmTest` does. And `NostrRepository.NO_OP_NOSTR_REPOSITORY.getProfileWithPublicKey` is a `TODO()`, so a resolver that asks it about anybody throws on a background dispatcher and surfaces as a timeout rather than a failure anybody can read; these read profiles from the seeded database instead, which also gives the honest answer for a stranger. 983 jvmTest, 447 testDebugUnitTest, no failures. Co-Authored-By: Claude Opus 5 --- .../database/dao/MantraProjectDaoJvmTest.kt | 36 ++ .../view/model/AddProjectViewModelJvmTest.kt | 299 ++++++++++++++++ .../model/AddToProjectViewModelJvmTest.kt | 276 ++++++++++++++ .../model/FrostSigningProjectNamesJvmTest.kt | 338 ++++++++++++++++++ .../ui/view/model/ProjectViewModelFixtures.kt | 235 ++++++++++++ 5 files changed, 1184 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt index 648d3d0b..9b03353a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt @@ -291,6 +291,42 @@ class MantraProjectDaoJvmTest { ) } + @Test + fun `a translation says which projects it is in`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId, name = "Gospel of John") + + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + translationArtifactVersionId = translationId, + chatRoomId = roomId, + signature = signature, + ) + ) + + // The artifact-side twin of this had a test from the start and the + // translation side did not, which is the kind of asymmetry a copied + // query hides: both joins are three lines and one of them could have + // named the wrong column without anything noticing. + assertEquals( + listOf("Gospel of John"), + db.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId(translationId) + .map { it.name } + ) + + // And it is specific to the translation rather than to the project. + assertTrue( + db.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId("9".repeat(64)) + .isEmpty() + ) + } + @Test fun `a membership naming a version this room does not hold is refused`() = runBlocking { seed() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt new file mode 100644 index 00000000..115558e0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt @@ -0,0 +1,299 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.room3.Room +import kotlinx.coroutines.CompletableDeferred +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 kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AddProjectUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * What the form actually puts in front of the group. + * + * A project and its first contents go out as one batch whose memberships carry + * the project's id -- and that id is a hash over the group's key at the room's + * derivation path, so it does not exist until the proposal is made. The screen + * therefore hands over a *function* of the signed lead rather than a list, and + * nothing downstream would notice if that function returned the wrong thing: a + * membership naming the wrong project is a valid event, signs cleanly, and + * lands as a row pointing somewhere else. + * + * So these assert the batch's shape against a recording repository that signs + * the lead the way a finished session does. Three properties, each of which has + * a silent failure behind it: + * + * - the memberships name the project that was signed, not one worked out + * beforehand; + * - both halves are in the batch, since the two lists come from two different + * `membershipsOf` calls and dropping one is a dropped selection; + * - the creator on the event is the member filling the form in, because there + * is no field for it and nothing else to check it against. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddProjectViewModelJvmTest { + + 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 fun viewModel(signing: RecordingFrostSigningRepository) = AddProjectViewModel( + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + relayHint = null, + initialAddProjectUIState = AddProjectUIState.Loading, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = DatabaseChatRepository(db, scope), + mantraRepository = DatabaseMantraRepository(db, scope), + frostSigningRepository = signing, + ) + + /** Opens the form and waits for the room and library to land. */ + private suspend fun loaded(signing: RecordingFrostSigningRepository): Pair { + val viewModel = viewModel(signing) + viewModel.initiateAddProject() + return viewModel to awaitValue { + viewModel.addProjectUIState as? AddProjectUIState.Loaded + } + } + + /** + * Proposes, and waits for whichever callback the view model reaches. + * + * Both fire on `Dispatchers.Main` from inside a `Dispatchers.IO` launch, so + * `addProject` returns long before either runs. + */ + private suspend fun propose( + viewModel: AddProjectViewModel, + state: AddProjectUIState.Loaded, + name: String = "Gospel of John into Sesotho", + description: String = "The four chapters.", + owners: Set = setOf(room.roomId), + sourceVersionIds: List = emptyList(), + translationIds: List = emptyList(), + ): String? { + val outcome = CompletableDeferred() + + viewModel.addProject( + localChatRoom = state.localChatRoom, + nameField = TextFieldState(name), + descriptionField = TextFieldState(description), + ownerPublicKeys = owners, + sourceVersionIds = sourceVersionIds, + translationIds = translationIds, + onSuccess = { outcome.complete(it) }, + onFailure = { outcome.complete(null) } + ) + + return withTimeout(10_000) { outcome.await() } + } + + // --- what the form offers --- + + @Test + fun `the form offers every version in the library and every translation`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (_, state) = loaded(signing) + + // Versions have no by-room query -- they hang off an artifact -- so this + // is the walk down the tree, and a walk that stopped at the first + // artifact would look like a library with one version in it. + assertEquals( + setOf(room.versionId, room.secondVersionId), + state.sourceVersions.map { it.id }.toSet() + ) + assertEquals(listOf(room.translationId), state.translations.map { it.id }) + assertTrue(state.canSign) + } + + // --- what goes to the group --- + + @Test + fun `the batch is the project, then its sources, then its translations`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + val sessionId = propose( + viewModel, state, + sourceVersionIds = listOf(room.versionId, room.secondVersionId), + translationIds = listOf(room.translationId), + ) + + assertEquals("session-1", sessionId) + + assertEquals( + listOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ), + signing.proposed.map { it.kind }, + "the project leads, because the memberships are derived from its id" + ) + } + + @Test + fun `every membership names the project the session signed`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose( + viewModel, state, + description = "", + sourceVersionIds = listOf(room.versionId), + translationIds = listOf(room.translationId), + ) + ) + + val project = signing.proposed.first() + + // The point of the whole lead-and-dependents shape. Nothing else checks + // this: a membership naming some other id is a valid signed event that + // lands as a row pointing at nothing. + val source = signing.proposed.single { it.kind == ProjectArtifactVersionEvent.KIND } + .let { + ProjectArtifactVersionEvent( + it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig + ) + } + assertEquals(project.id, source.projectId()) + assertEquals(room.versionId, source.artifactVersionId()) + + val translation = signing.proposed + .single { it.kind == ProjectTranslationArtifactVersionEvent.KIND } + .let { + ProjectTranslationArtifactVersionEvent( + it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig + ) + } + assertEquals(project.id, translation.projectId()) + assertEquals(room.translationId, translation.translationArtifactVersionId()) + + // And they read as one act rather than four events sharing a session. + assertTrue(signing.proposed.all { it.createdAt == project.createdAt }) + } + + @Test + fun `the creator is the member filling the form in, and the owners are what was chosen`() = + runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose(viewModel, state, owners = setOf(room.roomId, room.otherMember)), + "a project with no contents is still a project" + ) + + val project = signing.proposed.single().let { + ProjectEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + // There is no creator field, so this is the only thing that says the + // right person is credited for what the group is about to sign. + assertEquals(room.member, project.creator()) + assertEquals(setOf(room.roomId, room.otherMember), project.owners().toSet()) + assertEquals("Gospel of John into Sesotho", project.name()) + assertEquals("The four chapters.", project.content) + + // The author is the room, not the creator: the group signs it. + assertEquals(room.roomId, signing.proposed.single().pubKey) + } + + @Test + fun `a project with no contents proposes one event`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose(viewModel, state, description = ""), + "an empty project is a name waiting for work" + ) + + assertEquals(listOf(ProjectEvent.KIND), signing.proposed.map { it.kind }) + } + + // --- what the form refuses to send --- + + @Test + fun `a blank name proposes nothing`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertNull( + propose(viewModel, state, name = " "), + "a nameless project should not reach a quorum" + ) + assertTrue(signing.proposed.isEmpty()) + } + + @Test + fun `an empty owner set proposes nothing rather than throwing`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + // `ProjectEvent.build` throws on an empty set, so the view model has to + // refuse first. The form disables the last remove, but the guard is what + // makes that a safety rather than the only thing standing in the way. + assertNull( + propose(viewModel, state, description = "", owners = emptySet()), + "a project with no owner cannot be built" + ) + assertTrue(signing.proposed.isEmpty()) + } + + @Test + fun `a room that cannot be found is an error rather than an empty form`() = runBlocking { + // Nothing seeded. + val viewModel = viewModel(RecordingFrostSigningRepository(room.roomId)) + + viewModel.initiateAddProject() + + val error = awaitValue { viewModel.addProjectUIState as? AddProjectUIState.Error } + + assertTrue(error.message.isNotBlank(), "the error should say something") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt new file mode 100644 index 00000000..e9e0d948 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import kotlinx.coroutines.CompletableDeferred +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 kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.MantraProjectArtifactVersion +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.ui.view.state.AddToProjectUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Which work the picker offers, and what one pick becomes. + * + * The filtering is the part that matters and the part nothing else would catch. + * A membership that duplicates one the group has already signed is a *valid* + * event with its own id -- the foreign keys are satisfied, the signature is + * good, the apply arm writes the row -- so there is no layer below this that + * says no. If the filter were wrong the only symptom would be a quorum spent + * saying something the group has already said, which looks like the feature + * working. + * + * The other half is that one pick is one event. The add-to flow knows the + * project's id because the project exists, so it uses the flat batch rather + * than lead-and-dependents, and a membership built against the wrong id would + * land as a row pointing somewhere else. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddToProjectViewModelJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + private val projectId = "p".repeat(64) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + private fun viewModel( + signing: RecordingFrostSigningRepository, + translations: Boolean, + ) = AddToProjectViewModel( + projectId = projectId, + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + translations = translations, + relayHint = null, + initialAddToProjectUIState = AddToProjectUIState.Loading, + chatRepository = DatabaseChatRepository(db, scope), + mantraRepository = DatabaseMantraRepository(db, scope), + frostSigningRepository = signing, + ) + + private suspend fun loaded( + signing: RecordingFrostSigningRepository, + translations: Boolean, + ): Pair { + val viewModel = viewModel(signing, translations) + viewModel.initiateAddToProject() + return viewModel to awaitValue { + viewModel.addToProjectUIState as? AddToProjectUIState.Loaded + } + } + + private suspend fun propose( + viewModel: AddToProjectViewModel, + state: AddToProjectUIState.Loaded, + selectedId: String, + ): String? { + val outcome = CompletableDeferred() + + viewModel.addToProject( + localChatRoom = state.localChatRoom, + selectedId = selectedId, + onSuccess = { outcome.complete(it) }, + onFailure = { outcome.complete(null) } + ) + + return withTimeout(10_000) { outcome.await() } + } + + /** A membership already signed, so the picker has something to leave out. */ + private suspend fun alreadyHolds(versionId: String) = + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = room.roomId, + projectId = projectId, + artifactVersionId = versionId, + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + + // --- which work is offered --- + + @Test + fun `the source picker offers every version in the library`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + assertEquals( + setOf(room.versionId, room.secondVersionId), + state.candidates.map { it.id }.toSet() + ) + assertTrue(state.canSign) + } + + @Test + fun `a version already in the project is not offered again`() = runBlocking { + room.seed() + room.project(id = projectId) + alreadyHolds(room.versionId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + // The whole point. Nothing below this refuses a duplicate: it is a valid + // event with its own id, so it would cost a quorum to say what the group + // has already said. + assertEquals(listOf(room.secondVersionId), state.candidates.map { it.id }) + } + + @Test + fun `a version is named by its artifact as well as its label`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + val candidate = state.candidates.single { it.id == room.versionId } + + // "1.0" on its own is not a choice -- a room has several of those. + assertEquals(room.versionLabel, candidate.label) + assertEquals(room.artifactName, candidate.detail) + } + + @Test + fun `the translation picker offers translations, not versions`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertEquals(listOf(room.translationId), state.candidates.map { it.id }) + assertEquals(room.translationName, state.candidates.single().label) + } + + @Test + fun `a translation already in the project is not offered again`() = runBlocking { + room.seed() + room.project(id = projectId) + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = room.roomId, + projectId = projectId, + translationArtifactVersionId = room.translationId, + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertTrue(state.candidates.isEmpty()) + } + + @Test + fun `the two halves filter independently`() = runBlocking { + room.seed() + room.project(id = projectId) + // A source membership must not hide a translation, and the translation + // this one names is of that very version -- which is what a filter + // written against the wrong table would get wrong. + alreadyHolds(room.versionId) + + val (_, translationState) = + loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertEquals(listOf(room.translationId), translationState.candidates.map { it.id }) + } + + // --- what one pick becomes --- + + @Test + fun `picking a source proposes one membership naming the project`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing, translations = false) + + assertEquals("session-1", propose(viewModel, state, room.versionId)) + + val proposed = signing.proposed.single() + assertEquals(ProjectArtifactVersionEvent.KIND, proposed.kind) + + val membership = ProjectArtifactVersionEvent( + proposed.id, proposed.pubKey, proposed.createdAt, + proposed.tags, proposed.content, proposed.sig + ) + assertEquals(projectId, membership.projectId()) + assertEquals(room.versionId, membership.artifactVersionId()) + } + + @Test + fun `picking a translation proposes the translation kind`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing, translations = true) + + assertEquals("session-1", propose(viewModel, state, room.translationId)) + + val proposed = signing.proposed.single() + assertEquals(ProjectTranslationArtifactVersionEvent.KIND, proposed.kind) + + val membership = ProjectTranslationArtifactVersionEvent( + proposed.id, proposed.pubKey, proposed.createdAt, + proposed.tags, proposed.content, proposed.sig + ) + assertEquals(projectId, membership.projectId()) + assertEquals(room.translationId, membership.translationArtifactVersionId()) + } + + @Test + fun `a session that will not open is reported rather than swallowed`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId).also { it.session = null } + val (viewModel, state) = loaded(signing, translations = false) + + assertNull(propose(viewModel, state, room.versionId)) + } + + // --- what it refuses to open at all --- + + @Test + fun `a project this device does not hold is an error`() = runBlocking { + room.seed() + // The room is there, the project is not. + val viewModel = viewModel(RecordingFrostSigningRepository(room.roomId), translations = false) + viewModel.initiateAddToProject() + + val error = awaitValue { viewModel.addToProjectUIState as? AddToProjectUIState.Error } + + assertTrue(error.message.isNotBlank()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt new file mode 100644 index 00000000..8bfb2abb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt @@ -0,0 +1,338 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +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.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.view.state.FrostSigningUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a member is actually shown before they sign something about a project. + * + * A membership event carries two 64-character ids and nothing else. Unresolved, + * the signing screen asks somebody to agree to "a4f1be55d0c3" -- which is not a + * question anybody can answer, and it is the whole reason + * `FrostSigningViewModel` resolves names before handing `ProposedEvent.summarize` + * a lookup. + * + * Nothing downstream notices if that resolution is wrong or missing. The summary + * still renders, the session still signs, and the member still taps approve. + * These tests are the only thing standing between the resolver and a screen that + * quietly goes back to showing hashes: + * + * - a membership resolves to the artifact and label a reader recognises; + * - a project's creator and outside owners resolve to people; + * - the room's own key is *not* named as an owner, because saying "this group" + * on every project teaches a member to skip the line that matters; + * - an id this device has never seen falls back to a short hash rather than + * inventing a name or dropping the line. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FrostSigningProjectNamesJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + private val projectId = "p".repeat(64) + private val sessionId = "session-1" + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + /** A session whose items are [events], which is all this screen reads. */ + private class StubSigning( + private val chatRoomId: String, + private val events: List, + private val delegate: FrostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + ) : FrostSigningRepository by delegate { + + override fun observeSessionById(sessionId: String): Flow = flowOf( + FrostSigningSession( + id = sessionId, + chatRoomId = chatRoomId, + coordinatorPublicKey = chatRoomId, + userPublicKey = chatRoomId, + dkgSessionId = "ceremony-1", + threshold = 2, + participantCount = 3, + signerId = 0, + ) + ) + + override fun observeMessages(sessionId: String): Flow> = + flowOf(emptyList()) + + override fun observeItems(sessionId: String): Flow> = flowOf( + events.mapIndexed { index, event -> + FrostSigningItem( + sessionId = sessionId, + itemIndex = index, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = "a".repeat(64), + ) + } + ) + } + + private fun signed(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = room.roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = room.roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "f".repeat(128), + ) + + /** + * The names the signing screen would be handed for [events]. + * + * The profile source is a real read of the seeded database rather than the + * no-op, whose `getProfileWithPublicKey` is a `TODO()` -- so a resolver that + * asks it about anybody throws on `Dispatchers.IO`, which surfaces as this + * helper timing out rather than as a failure anybody can read. Reading the + * database gives the honest answer for a stranger too: null. + */ + private suspend fun namesFor(events: List): Map { + val viewModel = FrostSigningViewModel( + chatRoomId = room.roomId, + sessionId = sessionId, + activeUserPublicKey = room.member, + initialFrostSigningUIState = FrostSigningUIState.Loading, + chatRepository = DatabaseChatRepository(db, scope), + frostSigningRepository = StubSigning(room.roomId, events), + mantraRepository = DatabaseMantraRepository(db, scope), + nostrRepository = ProfileReadingNostrRepository(db), + ) + + viewModel.initiate() + + return awaitValue { + (viewModel.frostSigningUIState as? FrostSigningUIState.Loaded) + ?.takeIf { it.items.isNotEmpty() } + ?.names + } + } + + private fun membership(versionId: String) = signed( + ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = versionId, + createdAt = 1_700_000_000, + ) + ) + + // --- a membership proposed on its own --- + + @Test + fun `a membership resolves to the artifact and the label`() = runBlocking { + room.seed() + room.project(id = projectId) + + val names = namesFor(listOf(membership(room.versionId))) + + assertEquals("${room.artifactName} ${room.versionLabel}", names[room.versionId]) + assertEquals("Gospel of John", names[projectId]) + } + + @Test + fun `and so the summary a member reads is words rather than a hash`() = runBlocking { + room.seed() + room.project(id = projectId) + + val event = membership(room.versionId) + val names = namesFor(listOf(event)) + + // The end the resolver exists for, asserted through the same call the + // signing screen makes. + val summary = ProposedEvent.summarize( + event = event, + chatRoomId = room.roomId, + resolve = names::get, + ) + + assertEquals("A source version joins the project", summary.label) + assertEquals("To Kill a Mockingbird 1.0 · to Gospel of John", summary.detail) + assertTrue( + !summary.detail.contains(room.versionId.take(12)), + "a resolved membership should not still be showing a hash" + ) + } + + @Test + fun `a translation membership resolves to the translation's name`() = runBlocking { + room.seed() + room.project(id = projectId) + + val event = signed( + ProjectTranslationArtifactVersionEvent.build( + projectId = projectId, + translationArtifactVersionId = room.translationId, + createdAt = 1_700_000_000, + ) + ) + + assertEquals(room.translationName, namesFor(listOf(event))[room.translationId]) + } + + // --- what cannot be resolved --- + + @Test + fun `a version this device has never seen is left to the hash`() = runBlocking { + room.seed() + room.project(id = projectId) + val absent = "9".repeat(64) + + val event = membership(absent) + val names = namesFor(listOf(event)) + + // Left out rather than guessed at. The device has not seen the thing, + // and a short hash says so; inventing a name would be worse than saying + // nothing. + assertTrue(absent !in names) + + val summary = ProposedEvent.summarize(event, room.roomId, names::get) + assertEquals(absent.take(12) + " · to Gospel of John", summary.detail) + } + + @Test + fun `a project being signed in the same batch resolves to nothing`() = runBlocking { + room.seed() + // No project row: this is the founding batch, where the project is on the + // line above and nothing has applied it yet. + val project = signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId), + createdAt = 1_700_000_000, + ) + ) + val source = signed( + ProjectArtifactVersionEvent.build( + projectId = project.id, + artifactVersionId = room.versionId, + createdAt = 1_700_000_000, + ) + ) + + val names = namesFor(listOf(project, source)) + + assertTrue( + project.id !in names, + "nothing has applied the project yet, which is why a membership says the version first" + ) + // And the version still resolves, which is what makes the batch readable. + assertEquals("${room.artifactName} ${room.versionLabel}", names[room.versionId]) + } + + // --- the people on a project --- + + @Test + fun `a project's creator resolves to a profile`() = runBlocking { + room.seed() + + // NostrRepository is the no-op here, so prove the wiring with a stub + // that answers for the member and nobody else. + val names = namesFor( + listOf( + signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId, room.otherMember), + createdAt = 1_700_000_000, + ) + ) + ) + ) + + assertEquals("kgothatso", names[room.member]) + } + + @Test + fun `an owner with no profile is left to the hash, and the room is left out of the line`() = + runBlocking { + room.seed() + + val project = signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId, room.otherMember), + createdAt = 1_700_000_000, + ) + ) + val names = namesFor(listOf(project)) + + assertTrue(room.otherMember !in names, "a stranger has no name to show") + + val summary = ProposedEvent.summarize(project, room.roomId, names::get) + + assertTrue(summary.detail.contains("started by kgothatso"), summary.detail) + assertTrue( + summary.detail.contains("owned by ${room.otherMember.take(12)}"), + summary.detail + ) + assertTrue( + !summary.detail.contains(room.roomId.take(12)), + "the room owning its own work is the ordinary case and says nothing" + ) + } + + /** Only the one method the resolver reaches for. */ + private class ProfileReadingNostrRepository( + private val db: MantraDatabase, + private val delegate: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + ) : NostrRepository by delegate { + override suspend fun getProfileWithPublicKey(publicKey: String) = + db.profileDao().getProfileByPublicKey(publicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt new file mode 100644 index 00000000..c7316fb5 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt @@ -0,0 +1,235 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +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.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.FrostSigningRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout + +/** + * Waits for [read] to stop returning null, or gives up. + * + * These view models hold their state in `mutableStateOf` rather than a flow -- + * the convention the rest of this family follows -- so there is nothing to + * collect, and `initiate` launches on `Dispatchers.IO`, so the call returns + * before the work is done. Polling in real time for the same reason + * `NavigationIdentityRoutingJvmTest` does: `runTest` would advance a virtual + * clock past a real database read rather than waiting for it. + */ +suspend fun awaitValue(timeoutMillis: Long = 10_000, read: () -> T?): T = + withTimeout(timeoutMillis) { + var value = read() + while (value == null) { + delay(5) + value = read() + } + value + } + +/** + * The room, the library and the translation every project view model needs + * before it has anything to do. + * + * Shared rather than copied three times: the chain is five foreign keys deep and + * none of it is what any of these tests are about. + */ +class ProjectRoom(private val db: MantraDatabase) { + val roomId = "b".repeat(64) + val signature = "0".repeat(128) + + val member = "a".repeat(64) + val otherMember = "7".repeat(64) + + val dialectId = "1".repeat(64) + val artifactId = "2".repeat(64) + val versionId = "3".repeat(64) + val secondVersionId = "6".repeat(64) + val translationId = "5".repeat(64) + + val artifactName = "To Kill a Mockingbird" + val versionLabel = "1.0" + val translationName = "Sesotho" + + suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = member, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert( + Profile(publicKey = member, userName = "kgothatso", nostrEventId = nostrEventId) + ) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = member, + subject = "a translation room", + description = null, + mlsGroupState = null, + ) + ) + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = artifactName, + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + listOf(versionId to versionLabel, secondVersionId to "2.0").forEach { (id, label) -> + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = id, + artifactId = artifactId, + publicKey = roomId, + versionLabel = label, + signature = signature, + chatRoomId = roomId, + ) + ) + } + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = versionId, + dialectId = dialectId, + name = translationName, + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + } + + /** A project already signed into the room, for the add-to flow to add to. */ + suspend fun project( + id: String = "p".repeat(64), + name: String = "Gospel of John", + owners: Set = setOf(roomId), + ): MantraProject = MantraProject( + id = id, + publicKey = roomId, + name = name, + description = "The four chapters.", + creatorPublicKey = member, + ownerPublicKeys = owners.sorted().joinToString(","), + chatRoomId = roomId, + signature = signature, + ).also { db.mantraProjectDao().upsert(it) } + + suspend fun localChatRoom(): LocalChatRoom = + db.chatRoomDao().findChatRoomById(roomId) ?: error("the room was not seeded") +} + +/** + * A signing repository that records what it was asked to sign instead of asking + * a group. + * + * Delegates everything else to the no-op, so it keeps compiling when the + * interface grows and says nothing about methods these tests are not aiming at. + * + * The lead-and-dependents overload is emulated rather than stubbed, because that + * is the part worth checking: the real one signs the lead first -- resolving the + * room's key and so the lead's id -- and only then asks the caller what refers + * to it. A fake that handed back an unsigned lead, or never called `dependents`, + * would let a batch that cannot work pass. + */ +class RecordingFrostSigningRepository( + private val authorPublicKey: HexKey, + private val delegate: FrostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, +) : FrostSigningRepository by delegate { + + /** Every event the session was opened over, lead first. */ + var proposed: List = emptyList() + private set + + /** Null to make the proposal fail, which is the screens' other path. */ + var session: FrostSigningSession? = FrostSigningSession( + id = "session-1", + chatRoomId = "b".repeat(64), + coordinatorPublicKey = authorPublicKey, + userPublicKey = authorPublicKey, + dkgSessionId = "ceremony-1", + threshold = 2, + participantCount = 3, + signerId = 0, + ) + + override suspend fun canSign(chatRoomId: String): Boolean = true + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): FrostSigningSession? { + proposed = events.map { signed(it) } + return session + } + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + lead: EventTemplate<*>, + dependents: (lead: Event) -> List> + ): FrostSigningSession? { + val signedLead = signed(lead) + proposed = listOf(signedLead) + dependents(signedLead).map { signed(it) } + return session + } + + /** The template as a finished session leaves it: authored by the room. */ + private fun signed(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = authorPublicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = authorPublicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "f".repeat(128), + ) +} From b95d723edd95d91b03f04333cc8aa6ec9a969ea0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:39:26 +0200 Subject: [PATCH 27/32] fix(chat): a tablet's room list can pick a second room, not just the first The home screen's detail pane answered with the first room it was ever given. Tap another and the list highlighted nothing, the transcript did not change, and the app bar kept the first room's title -- so from the expanded breakpoint up, which is a tablet in landscape and every desktop window, the split was a one-shot: the first conversation you opened was the only one you could read without leaving the screen. **`key(chatRoomId)` does not rebuild a view model, and the comment claiming it did was the bug.** `viewModel()` resolves against `LocalViewModelStoreOwner.current` under a key derived from the *class* -- `ViewModelProvider.get(modelClass)`, no key, straight to the default `DEFAULT_KEY:canonicalName`. A composition key replaces a group: `remember`s are forgotten and `LaunchedEffect`s restart, which is real but is not where a view model lives. The store belongs to `HomeRoute`'s `NavBackStackEntry` and does not change while the reader is on the home screen, so the second tap re-ran the composition, asked for a `ChatRoomMessagingViewModel`, and got back the one holding room one -- its `chatRoomId` a constructor val, its factory never invoked a second time. `initiateChatRoomDetail()` then re-read room one and the pane redrew what it already had. **This is a list-detail-only failure, which is why nothing caught it.** Every other screen in the app reaches the chat transcript by `navController.navigate(...)`, and a pushed route is its own `NavBackStackEntry` and so its own store -- one subject per owner, the class key never collides. The pane is the one place one screen is composed for a succession of subjects without the entry changing. Below expanded the home screen still pushes, so a phone never had this; the four existing cases in `ChatPaneLayoutJvmTest` assert the widths and the empty state, which were right. **`ViewModelStoreScopedTo` gives the pane a store of its own rather than keying the two `viewModel()` calls by room id.** Both put the right room on screen. Keying would also leave every room visited holding a live view model in the home entry's store until the reader left the screen, each with its relay subscriptions and database collectors still running -- a leak that grows with how much a reader browses, which on this layout is the point of the layout. A scope that clears on the way out is the same correctness and none of that. **It is hand-rolled because the stock API is not here.** `rememberViewModelStoreOwner` lands in `androidx.lifecycle:lifecycle-viewmodel-compose` 2.11.0 and does exactly this, including surviving an Android configuration change by consulting the parent's lifecycle; the build resolves `org.jetbrains.androidx.lifecycle`, and the class is in neither the 2.9.6 nor the 2.11.0 artifact in the cache. A bare `ViewModelStoreOwner` is the whole contract a scope needs here -- nothing in this app puts a `SavedStateHandle` in a view model, so there is no saved state to delegate, and it deliberately does not implement `HasDefaultViewModelProviderFactory` because every call inside passes its own factory. The cost of hand-rolling is that a rotation rebuilds the pane's view models instead of keeping them; the selection itself is `rememberSaveable` and survives, so what that costs is a re-read of one room. **Both wrappers stay, and they are not redundant.** The store scope alone would leave the composer's draft text in place as the reader moved to the next room -- which on this screen is the failure the composer is built around three separate signals to prevent, one person's words going to another -- and would not restart the effect that loads a room, so the pane would sit on `Loading`. `key` alone was the bug. One resets the composition, the other the view models. **Tests: a fifth case in `ChatPaneLayoutJvmTest`, checked against the bug reinstated.** Two rooms told apart by their subject, which both panes draw as a title, so two nodes saying it means "this is the room on the right" and one means "this room is merely listed" -- an assertion that needs no new test tag and no reach into the view model. Tap room one, wait for two; tap room two, wait for two of *it* and one of room one. With `ViewModelStoreScopedTo` reduced to `content()` the second wait fails with `ComposeTimeoutException` while the other four cases still pass, so the case pins the view model scope rather than the layout. The waits are generous, because what they wait for is a database read on another dispatcher and the cost of being wrong about a thread hop is a test that fails on a loaded machine. **Not done.** Nobody has looked at this running on a tablet; the test proves the pane follows the selection, which is not the same thing. The list pane also still draws no selected state -- M3's list-detail expects the selected row to carry one, and its absence is its own reason the pane read as unresponsive. Left out deliberately: it is a separate change to a different file, and this one is the part that was broken. 974 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../compose/ui/composable/HomeScreen.kt | 15 +++- .../widgets/ViewModelStoreScopedTo.kt | 51 +++++++++++ .../ui/composable/ChatPaneLayoutJvmTest.kt | 87 +++++++++++++++++-- 3 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ViewModelStoreScopedTo.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index 9cf1ec42..6f525349 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -80,6 +80,7 @@ import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_p import mantra.composeapp.generated.resources.sign_in_with_the_nsec import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.ViewModelStoreScopedTo import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @@ -311,10 +312,17 @@ fun HomeScreen( message = stringResource(Res.string.pick_a_conversation_to_read_it_here) ) } else { - // Keyed on the room, so switching rooms rebuilds the - // screen's view models rather than feeding a new id to - // ones already subscribed to another room's relay. + // Two things, and the pane needs both. `key` rebuilds + // the composition -- the composer's draft text does not + // follow you into the next room, and the effect that + // loads a room runs again. `ViewModelStoreScopedTo` + // rebuilds the view models, which `key` cannot: they + // hang off the store, and on this screen the store is + // `HomeRoute`'s and never changes, so without it the + // pane kept answering with the first room it was given + // however many were tapped after it. key(chatRoomId) { + ViewModelStoreScopedTo(chatRoomId) { ChatRoomMessagingScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, @@ -333,6 +341,7 @@ fun HomeScreen( onNavigateBack = null, ) } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ViewModelStoreScopedTo.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ViewModelStoreScopedTo.kt new file mode 100644 index 00000000..5577b429 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ViewModelStoreScopedTo.kt @@ -0,0 +1,51 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner + +/** + * Gives [content] a `ViewModelStore` of its own, cleared whenever [scopeKey] changes. + * + * **`key(...)` does not do this, which is the whole reason this exists.** A composition + * key replaces a group -- `remember`s are forgotten, `LaunchedEffect`s restart -- but + * `viewModel()` is not composition state. It resolves against + * `LocalViewModelStoreOwner.current` under a key derived from the *class*, so two + * compositions of one screen under one owner get the same instance no matter how they are + * keyed, and the factory is never invoked for the second. Being inside `key(someId)` buys + * nothing: the store is the navigation entry's, and it outlives every group in it. + * + * That is only a problem where one screen is composed for a succession of subjects without + * the navigation entry changing, which in this app means a list-detail pane: the home + * screen stays on `HomeRoute` while the detail pane is asked for one chat room after + * another. A pushed route has no such problem, because each `NavBackStackEntry` is already + * a store of its own -- which is why this only wraps the pane. + * + * The store is cleared on the way out rather than left to the parent, so the room just + * left does not keep its relay subscriptions and database collectors running. That is the + * other half of the job: keying the `viewModel()` calls by room id instead would fix what + * is on screen and quietly accumulate a live view model per room visited. + * + * Nothing in this app puts a `SavedStateHandle` in a view model, so a bare + * `ViewModelStoreOwner` is the whole contract a scope needs here; it deliberately does not + * implement `HasDefaultViewModelProviderFactory`, because every call inside passes its own + * factory. + */ +@Composable +fun ViewModelStoreScopedTo(scopeKey: Any?, content: @Composable () -> Unit) { + val owner = remember(scopeKey) { + object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + } + + DisposableEffect(owner) { + onDispose { owner.viewModelStore.clear() } + } + + CompositionLocalProvider(LocalViewModelStoreOwner provides owner, content = content) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt index ac4d48b7..937a68aa 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt @@ -3,12 +3,16 @@ package press.mantra.compose.ui.composable import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -21,12 +25,14 @@ import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.HomeScreenUIState import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import androidx.compose.ui.test.ComposeUiTest import kotlin.test.Test +import kotlin.test.assertEquals /** * The chat list-detail split, measured in windows of the widths the phase names. * - * Three claims, and none of them can be read off the source: + * Four claims, and none of them can be read off the source: * * - below expanded the home screen is unchanged. That is the promise that let the split * land as one commit -- the chat room is a navigation destination reached from eleven @@ -35,7 +41,10 @@ import kotlin.test.Test * `calculatePaneScaffoldDirective` gives it, so a hand-built pair measures the same as * a `ListDetailPaneScaffold` would; * - the detail pane says what it is for while nothing is selected, rather than being an - * unexplained empty half of the window. + * unexplained empty half of the window; + * - and a second tap in the list changes what the detail pane shows. That one is not a + * layout claim at all -- it is about view model scope -- but it is only reachable + * through this layout, and it is the way the split visibly failed on a tablet. * * The repositories are the no-op ones with the two reads this screen makes delegated to a * fixed answer. Kotlin's interface delegation makes that ten lines rather than a @@ -47,6 +56,15 @@ class ChatPaneLayoutJvmTest { private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" private val emptyDetail = "Pick a conversation to read it here." + private val roomOne = "Room one" + private val roomTwo = "Room two" + + /** + * Opening a room is a database read on another dispatcher, so the assertions wait + * rather than assume. Generous, because what it is waiting for is a thread hop and the + * cost of being wrong about it is a test that fails on a loaded machine. + */ + private val OpenTimeout = 5_000L @Test fun `a phone window is one pane, as it has always been`() = runDesktopComposeUiTest(400, 900) { @@ -81,8 +99,32 @@ class ChatPaneLayoutJvmTest { onNodeWithTag(ChatListPaneTag).assertWidthIsEqualTo(412.dp) } + @Test + fun `tapping a second room in the list changes what the detail pane shows`() = + runDesktopComposeUiTest(1000, 900) { + setContent { Home(chatRepository = TwoRooms) } + + // Each room's subject is drawn twice once it is the selection -- on its card in + // the list, and as the title of the detail pane's app bar -- so two nodes is + // "this is the room on the right" and one is "this room is merely listed". + onNodeWithText(roomOne).performClick() + waitUntil("room one opens", OpenTimeout) { nodesSaying(roomOne) == 2 } + + onNodeWithText(roomTwo).performClick() + + // Before the pane scoped its view models this timed out: `key(chatRoomId)` was + // rebuilding the composition around a `ChatRoomMessagingViewModel` that + // `viewModel()` kept resolving, by class, out of the home route's store -- so + // the pane answered with room one however many rooms were tapped after it. + waitUntil("room two replaces it", OpenTimeout) { + nodesSaying(roomTwo) == 2 && nodesSaying(roomOne) == 1 + } + assertEquals(2, nodesSaying(roomTwo), "room two is listed and shown") + assertEquals(1, nodesSaying(roomOne), "room one is listed, not shown") + } + @androidx.compose.runtime.Composable - private fun Home() { + private fun Home(chatRepository: ChatRepository = NoRooms) { MantraTheme { // Required, and deliberately so: `LocalSnackbarHostState` throws rather than // defaulting, because a default would make every `notify` on a screen that @@ -95,7 +137,7 @@ class ChatPaneLayoutJvmTest { onNavigateToDirectMessageDetail = {}, onNavigateToChatRoomCreation = {}, nostrRepository = FixedProfile, - chatRepository = NoRooms, + chatRepository = chatRepository, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, ) @@ -128,9 +170,36 @@ class ChatPaneLayoutJvmTest { } /** The no-op repository, plus the one read the room list makes of it. */ - private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { - override suspend fun observeChatRoomListByPublicKey( - publicKey: String, - ): Flow> = flowOf(emptyList()) - } + private val NoRooms = rooms() + + /** Two rooms, told apart by their subject, which is what both panes draw as a title. */ + private val TwoRooms = rooms(room("1", roomOne), room("2", roomTwo)) + + /** + * The no-op repository, plus the two reads this pair of panes makes of it: the list + * observes every room, and the detail pane looks one up by id. + */ + private fun rooms(vararg rooms: LocalChatRoom) = + object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeChatRoomListByPublicKey( + publicKey: String, + ): Flow> = flowOf(rooms.toList()) + + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = + rooms.firstOrNull { it.chatRoom.id == id } + } + + private fun room(id: String, subject: String) = LocalChatRoom( + chatRoom = ChatRoom( + id = id, + userPublicKey = publicKey, + subject = subject, + description = null, + initialGiftWrapPayloadId = null, + mlsGroupState = null, + ), + ) + + private fun ComposeUiTest.nodesSaying(text: String) = + onAllNodesWithText(text).fetchSemanticsNodes().size } From 9be3e01d8b4ada21f1a7e6c6fc40b564bf0be488 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:51:26 +0200 Subject: [PATCH 28/32] fix(subgroups): hand an adoption's outcome back on the main thread "Add a parent group", then tapping any group, crashed outright: IllegalStateException: Method setCurrentState must be called on the main thread at androidx.lifecycle.LifecycleRegistry.enforceMainThreadIfNeeded ... at NavController.popBackStack at SelectAdoptingGroupScreen.kt:153 at SelectAdoptingGroupViewModel$offerTo$1.invokeSuspend `offerTo` does its work in `viewModelScope.launch(Dispatchers.IO)` and called `onDone` from inside it. The screen's callback called `onNavigateBack()` straight through, which is `navController.popBackStack()` -- and navigation moves a back stack entry's lifecycle, which is main-thread-only. Nothing in the chain switched. **The dispatcher is the view model's business, not every caller's**, which is the shape the app already uses in a dozen places and in both of the files this one was modelled on: `SelectSubgroupAdminsViewModel.confirm` wraps its navigation in `withContext(Dispatchers.Main)` and `AddArtifactViewModel` launches its `onSuccess` there. A `finish` helper does it here, with the crash written above it so the next person to add a callback to this class sees why. `ChatRoomDetailViewModel.acceptParent` gets the same treatment: its only caller shows a snackbar, which happens to be main-safe already, but a callback contract that is "safe if you are careful" is the same bug waiting for a second caller. **And the screen no longer navigates away on success**, which fixes a second thing found on the way to the first. The snackbar was launched in `rememberCoroutineScope()` -- this screen's own -- and then the screen popped, so the message was racing its own scope's cancellation; `rememberNotifier` exists for exactly that hazard and was not being used. Staying put is the better answer anyway, because the list is the confirmation: the row just asked leaves it, since a certificate now stands for the pair and `refuseAdoption` says so. The way out is the back button, which a pushed screen has to have regardless. `offerTo` reloads before handing back so that is true by the time the message appears. The outer `scope.launch` around `offerTo` goes too -- it never suspended, and wrapping a main-thread call in a launch was part of what made the threading hard to see. No test covers this and none of the 1,416 would have. It is a dispatcher boundary between a view model and a navigation callback, which is to say it lives in the one layer this work had never run: compile-and-test verified, never driven through the app. That was named as the risk when the feature landed, and this is it. 454 common tests, 962 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../composable/SelectAdoptingGroupScreen.kt | 33 ++++++++++--------- .../ui/view/model/ChatRoomDetailViewModel.kt | 11 +++++-- .../model/SelectAdoptingGroupViewModel.kt | 27 +++++++++++++-- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt index 16313ef2..76fe9e43 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.launch import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.asked_that_group_to_adopt_this_one import mantra.composeapp.generated.resources.no_group_here_can_adopt_this_one @@ -40,6 +39,7 @@ 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.rememberNotifier import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -52,8 +52,15 @@ import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState * Tapping a row asks that group's quorum to certify this room as its child. That * is an **offer** and nothing more: this room becomes their subgroup only once * its own quorum re-announces its key state naming them, which is a card back on - * the room's own screen. So this hands back rather than going anywhere new, and - * the snackbar says what was asked rather than what was done. + * the room's own screen. So the snackbar says what was *asked* rather than what + * was done. + * + * **It does not navigate away on success**, and that is the fix for two things at + * once. A message launched here while the screen was being popped was racing its + * own scope's cancellation; and the list is the better confirmation anyway -- the + * row that was just asked leaves it, because a certificate now stands for the + * pair and `refuseAdoption` says so. The way out is the back button, which a + * pushed screen has to have regardless. * * The list is the profile's own rooms -- see `SelectAdoptingGroupViewModel` for * why it can be nothing else -- so empty is an ordinary answer and the empty @@ -80,12 +87,10 @@ fun SelectAdoptingGroupScreen( LaunchedEffect(chatRoomId) { viewModel.initiate() } val snackbarHostState = LocalSnackbarHostState.current - val scope = rememberCoroutineScope() - // Read outside the handler: both of these are composable and an onClick - // lambda is not. The notifier takes the caller's scope because the message is - // shown as this screen navigates away, and one launched in the departing - // composable's scope would be cancelled with it. + // Both read outside the handler, because both are composable and an onClick + // lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) val asked = stringResource(Res.string.asked_that_group_to_adopt_this_one) ScreenStateTransition(viewModel.uiState) { uiState -> @@ -145,13 +150,11 @@ fun SelectAdoptingGroupScreen( Card( modifier = Modifier.fillMaxWidth(), onClick = { - scope.launch { - viewModel.offerTo(candidate.chatRoom.id) { refusal -> - scope.launch { - snackbarHostState.showSnackbar(refusal ?: asked) - } - if (refusal == null) onNavigateBack() - } + // `offerTo` is not suspending and calls back + // on the main thread, so there is nothing to + // launch around it. + viewModel.offerTo(candidate.chatRoom.id) { message -> + notify(message ?: asked) } } ) { 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 9d7b7ade..6b7c16f3 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 @@ -22,6 +22,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import press.mantra.compose.database.model.types.MarmotReindexReport class ChatRoomDetailViewModel( @@ -118,7 +119,13 @@ class ChatRoomDetailViewModel( if (!accepted) { logger.e("Failed to ask $chatRoomId to accept $parentChatRoomId") - onDone("Couldn't ask this group to accept that parent. Please try again.") + // On the main thread, because a caller's callback may touch + // things an IO coroutine must not -- navigation most of all. See + // `SelectAdoptingGroupViewModel.finish`, where calling back from + // IO crashed outright. + withContext(Dispatchers.Main) { + onDone("Couldn't ask this group to accept that parent. Please try again.") + } return@launch } @@ -126,7 +133,7 @@ class ChatRoomDetailViewModel( // which is a quorum away -- so this reloads rather than crossing it // off, and the card stays until the group has actually agreed. initiateChatRoomDetail() - onDone(null) + withContext(Dispatchers.Main) { onDone(null) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt index a46ef417..d63d1fff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt @@ -14,6 +14,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState @@ -94,7 +95,7 @@ class SelectAdoptingGroupViewModel( if (refusal != null) { isActionPending.value = false - onDone(refusal) + finish(refusal, onDone) return@launch } @@ -108,14 +109,34 @@ class SelectAdoptingGroupViewModel( if (!proposed) { logger.e("Failed to ask $adoptingChatRoomId to adopt $chatRoomId") - onDone("Couldn't ask that group to adopt this one. Please try again.") + finish("Couldn't ask that group to adopt this one. Please try again.", onDone) return@launch } - onDone(null) + // Reload before handing back, so the row that was just asked leaves + // the list: a certificate now stands for the pair and + // `refuseAdoption` says so. That is the confirmation, and it is a + // better one than a message -- the list is the state. + initiate() + finish(null, onDone) } } + /** + * Hands [message] back **on the main thread**. + * + * The work runs on `Dispatchers.IO` and a caller's callback routinely touches + * things that may not be touched from there -- the first version of this + * screen called `popBackStack()` straight out of the IO coroutine and crashed + * on `Method setCurrentState must be called on the main thread`. The + * dispatcher is this function's business rather than every caller's, which is + * the shape `AddArtifactViewModel` and `SelectSubgroupAdminsViewModel` already + * use for the same handoff. + */ + private suspend fun finish(message: String?, onDone: (String?) -> Unit) { + withContext(Dispatchers.Main) { onDone(message) } + } + companion object { private const val TAG = "SelectAdoptingGroupViewModel" From fbf376fe20a576130f452e5648933f5f9c5937fc Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:51:30 +0200 Subject: [PATCH 29/32] feat(chat): the room list marks the room the pane is answering with The commit before this one made a second tap change the transcript. It did not make the list say which room the transcript belongs to, and on a tablet those are two halves of one question: a reader choosing their next room is looking at the list, not at the app bar on the other side of the window that repeats the title. With nothing marked, the only evidence of a selection was in the half of the screen they had looked away from -- which is its own reason the pane read as unresponsive even once it worked. **`secondaryContainer`, which is M3's container for a selected list item**, set through `CardDefaults.cardColors` so the content colour comes with it: `cardColors`' `contentColor` defaults to `contentColorFor(containerColor)`, so the row's text moves to `onSecondaryContainer` without a second call site to keep in step. A row's title keeps its `ProfileColor`, which is a fixed-lightness HSL colour and already scheme-blind, so the swap does not change what that line is contrasted against in either scheme -- a pre-existing question this commit neither fixes nor worsens. **`isSelected` is a predicate, and nullable, because the two layouts disagree about whether selection exists at all** -- not merely about its value. Where the list is the whole screen, tapping a room navigates to it and the list is about to be gone: no room is "the selected one", and both a highlight left behind on the way out and a reader told "not selected" about every room would be describing a pane that is not on the screen. `null` says that. A predicate answering false for every room does not, because that is also exactly what a two-pane layout looks like before the first tap, and those two states are different things for the list to be saying. **The call site reads the layout rather than the selection**, `listPaneWidth == null` rather than `selectedChatRoomId == null`. The selection is `rememberSaveable` and outlives a window that narrows, so a tablet turned from landscape to portrait keeps it -- and deriving the mark from the selection would leave a highlighted row on a one-pane screen with no pane beside it. **The semantics property is set only where it means something.** A row in a selection-bearing list carries `selected`, true or false, so a screen reader can say which of them the pane is showing; a row in a list that navigates carries nothing, rather than announcing "not selected" about a pane that does not exist. `Modifier.then` of an empty `Modifier` is how the row opts out, which keeps the branch at the one place that knows. **Tests: 2 cases, both checked against the feature removed.** The first taps two rooms in a 1000dp window and asserts the mark lands on the right card, moves rather than accumulating, and is absent before the first tap while the rows are already selectable -- "asked nothing yet" and "not the one being shown" being the two states the nullable predicate exists to tell apart. The second asserts a 700dp list has no selectable row at all, in two halves on purpose: a list that never rendered also has no selectable rows, so it checks the room is on screen before checking it is not selectable. Both first drafts counted `isSelectable()` across the whole window and failed by exactly one, because `PrimaryScrollableTabRow`'s `Tab` is selectable too. The assertions are scoped to the rows through a matcher that requires both selectability and the room's subject -- which works because `Card(onClick = ...)` merges its descendants, so one node carries the subject and the selected state together. That merge is also why a selected room is two nodes saying its subject and a merely listed one is a single node, which is what the previous commit's case counts. **Not done.** Still nobody has looked at this on a tablet. The tests prove the mark follows the selection and that a one-pane list stays silent; they say nothing about whether the colour reads as a selection at arm's length on a real screen. 976 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../compose/ui/composable/HomeScreen.kt | 13 +++- .../ui/view/model/ChatRoomListViewModel.kt | 42 +++++++++++- .../ui/composable/ChatPaneLayoutJvmTest.kt | 66 ++++++++++++++++++- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index 6f525349..638c6497 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -243,7 +243,18 @@ fun HomeScreen( key(true) { chatRoomListViewModel.RenderFeed( - onNavigateToDirectMessageDetail = onOpenChatRoom + onNavigateToDirectMessageDetail = onOpenChatRoom, + // Only where there is a pane to be selected + // into. The selection outlives a window + // that narrows -- it is `rememberSaveable`, + // and a tablet turned to portrait keeps it + // -- so this is read from the layout rather + // than from whether a room is selected. + isSelected = if (listPaneWidth == null) { + null + } else { + { id -> id == selectedChatRoomId } + }, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index e3a988f9..3bd3ff61 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -20,6 +21,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel @@ -144,7 +147,20 @@ class ChatRoomListViewModel( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RenderFeed( - onNavigateToDirectMessageDetail: (press.mantra.compose.ui.composable.navigation.routes.Route) -> Unit + onNavigateToDirectMessageDetail: (press.mantra.compose.ui.composable.navigation.routes.Route) -> Unit, + /** + * Which room the detail pane beside this list is showing, or `null` where this + * list is the whole screen. + * + * A predicate rather than an id because the two layouts disagree about whether + * selection is a concept at all, not merely about its value. Where tapping a room + * navigates to it, no room is "the selected one" -- the list is about to be gone -- + * and a highlight left behind on the way out, or a reader told "not selected" + * about every room in the list, would both be describing a pane that is not there. + * `null` says that; a predicate that answers false for every room would not, and + * is also what a two-pane layout looks like before the first tap. + */ + isSelected: ((String) -> Boolean)?, ) { Column( modifier = Modifier.fillMaxWidth(), @@ -174,8 +190,30 @@ class ChatRoomListViewModel( items = chatRoomListUIState.chatRoomList, key = { it.chatRoom.id } ) { localChatRoom -> + // Null where there is no pane, so the row says nothing + // about selection at all rather than saying "no". + val selected = isSelected?.invoke(localChatRoom.chatRoom.id) + Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .then( + if (selected == null) Modifier + else Modifier.semantics { this.selected = selected } + ), + // M3's container for a selected list item. The + // colour is the only signal a reader scanning the + // list gets about which room the pane is answering + // with -- the pane's own app bar repeats the title, + // but that is the half of the window they are not + // looking at while choosing the next room. + colors = if (selected == true) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + } else { + CardDefaults.cardColors() + }, onClick = { onNavigateToDirectMessageDetail.invoke( press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt index 937a68aa..4d436864 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt @@ -2,7 +2,11 @@ package press.mantra.compose.ui.composable import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isSelectable import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -32,7 +36,7 @@ import kotlin.test.assertEquals /** * The chat list-detail split, measured in windows of the widths the phase names. * - * Four claims, and none of them can be read off the source: + * Five claims, and none of them can be read off the source: * * - below expanded the home screen is unchanged. That is the promise that let the split * land as one commit -- the chat room is a navigation destination reached from eleven @@ -42,9 +46,11 @@ import kotlin.test.assertEquals * a `ListDetailPaneScaffold` would; * - the detail pane says what it is for while nothing is selected, rather than being an * unexplained empty half of the window; - * - and a second tap in the list changes what the detail pane shows. That one is not a + * - a second tap in the list changes what the detail pane shows. That one is not a * layout claim at all -- it is about view model scope -- but it is only reachable - * through this layout, and it is the way the split visibly failed on a tablet. + * through this layout, and it is the way the split visibly failed on a tablet; + * - and the list says which room the pane is answering with, in the one layout where + * that question exists. * * The repositories are the no-op ones with the two reads this screen makes delegated to a * fixed answer. Kotlin's interface delegation makes that ten lines rather than a @@ -123,6 +129,49 @@ class ChatPaneLayoutJvmTest { assertEquals(1, nodesSaying(roomOne), "room one is listed, not shown") } + @Test + fun `the list marks the room the detail pane is showing`() = + runDesktopComposeUiTest(1000, 900) { + setContent { Home(chatRepository = TwoRooms) } + + // Selectable, and nothing selected. A pane that has been asked nothing yet is + // a different state from a room that is not the one being shown, and the list + // is allowed to say so. `card` only matches a selectable node, so finding both + // rooms through it is half of the claim. + card(roomOne).assertIsNotSelected() + card(roomTwo).assertIsNotSelected() + + card(roomOne).performClick() + waitUntil("room one opens", OpenTimeout) { nodesSaying(roomOne) == 2 } + card(roomOne).assertIsSelected() + card(roomTwo).assertIsNotSelected() + + card(roomTwo).performClick() + waitUntil("room two opens", OpenTimeout) { nodesSaying(roomTwo) == 2 } + card(roomTwo).assertIsSelected() + + // The mark moves rather than accumulating. Two highlighted rows beside one + // transcript would say the pane was showing both. + card(roomOne).assertIsNotSelected() + } + + @Test + fun `a one-pane list says nothing about selection, having nothing to select into`() = + runDesktopComposeUiTest(700, 900) { + setContent { Home(chatRepository = TwoRooms) } + + // Tapping navigates here, and the test's callback is a no-op, so the list is + // still on screen afterwards -- which is the only reason this can be asked at + // all. A row answering "not selected" would be describing a pane that is not + // there, and a highlight left behind would outlive the screen it belonged to. + onNodeWithText(roomOne).performClick() + + // Both halves, because either alone passes for the wrong reason: a list that + // never rendered has no selectable rows either. + onNodeWithText(roomOne).assertExists() + card(roomOne).assertDoesNotExist() + } + @androidx.compose.runtime.Composable private fun Home(chatRepository: ChatRepository = NoRooms) { MantraTheme { @@ -202,4 +251,15 @@ class ChatPaneLayoutJvmTest { private fun ComposeUiTest.nodesSaying(text: String) = onAllNodesWithText(text).fetchSemanticsNodes().size + + /** + * The list's card for the room with this subject. + * + * `Card(onClick = ...)` merges its descendants, so one node carries both the subject + * and the selected state -- which is also why a selected room is two nodes saying its + * subject and a merely listed one is a single node. The detail pane's app bar says the + * same subject and is not selectable, so this stays unambiguous once a room is open. + */ + private fun ComposeUiTest.card(subject: String) = + onNode(isSelectable() and hasText(subject)) } From f46a7c627d1db8c9b2d2ca98a2963a77dd35c9ee Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 21:22:29 +0200 Subject: [PATCH 30/32] test(subgroups): stop a ceremony-queue test throwing into another test's name `./gradlew :composeApp:jvmTest` failed `NavigationRoutingTest > an account already on the device does not stay on the loading screen` with `UncaughtExceptionsBeforeTest: There were uncaught exceptions before the test started`. **"Before the test started" is the tell.** `runTest` reports exceptions that reached the default handler before it began, so the name on the failure is whichever `runTest` ran next -- not the one that threw. `NavigationRoutingTest` passes alone and the subgroups work never touched it. The thrower is `CeremonyQueueRoutingJvmTest`, and the chain is five links with no branch in it: 1. `awaiting()` builds a `ChatMessageListViewModel` on the bare `NO_OP_NOSTR_REPOSITORY` and calls `initiate()`. 2. `initiate()`'s second line is `scheduleSynchronization()`. 3. That launches on `Dispatchers.IO` and asks `MemberProfileSync` who cannot be named. 4. The fixture's `room()` gives every `LocalParticipant` a `profile = null`, so the answer is never empty and the next line always runs. 5. `queueNegentropySynchronizeRequest` is one of the **forty-one** `TODO("Not yet implemented")` bodies on that no-op, out of forty-five members. It throws in a coroutine nobody awaits, four times a run -- once per test in the class. Fixed the way this file already fixes it for `DkgRepository`: an `object : X by NO_OP_X` with the one member overridden. The comment above it carries the chain, because the next person to see `UncaughtExceptionsBeforeTest` will be reading the name of the wrong test. **Verified under the condition that reproduced it.** It is order-dependent, so a filtered run is not evidence -- the culprit passes alone, and the pair passed too. The full suite under `--rerun-tasks` failed reproducibly before and is 982 of 982 after. **Not fixed here: the landmine itself.** Sixty `TODO()` bodies sit across four no-op repositories -- 41 of 45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on `MarmotRepository`, 1 of 1 on `SearchRepository` -- and seventeen jvmTest files build view models on the Nostr one alone, so this will recur; the reporter had already hit it through `getProfileWithPublicKey`. It is not a sweep, though: "no-op" means something different for each member, the return types vary from `Unit` to `Flow`, and a blanket empty body could make a test pass vacuously where it currently fails honestly. Sixty judgement calls about shared code do not belong in a commit about one flake, so it is flagged for its own change. 982 jvm tests, 0 failed, under `--rerun-tasks`. Co-Authored-By: Claude Opus 5 --- .../view/model/CeremonyQueueRoutingJvmTest.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt index f6c876cb..faaec773 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.delay import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalParticipant @@ -151,7 +152,23 @@ class CeremonyQueueRoutingJvmTest { val viewModel = ChatMessageListViewModel( initialChatMessageListUIState = ChatMessageListUIState.Loading, localChatRoom = room(), - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + // Not the bare no-op. `initiate` calls `scheduleSynchronization` on + // its second line, which launches on `Dispatchers.IO` and -- because + // `room()` gives every participant a null profile, so there is always + // somebody it cannot name -- reaches + // `queueNegentropySynchronizeRequest`. That member is one of the + // forty-one `TODO("Not yet implemented")` bodies on + // NO_OP_NOSTR_REPOSITORY, so it throws in a coroutine nobody awaits. + // + // Nothing catches it, so it reaches the default handler, and the next + // `runTest` in the same JVM reports it as `UncaughtExceptionsBeforeTest` + // -- against its own name. That is how this test failed + // `NavigationRoutingTest` four times a run while passing itself. + nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun queueNegentropySynchronizeRequest( + negentropySynchronizeRequests: List + ) = Unit + }, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { From 395bfa6b8b1c8b4516a540b975e01c2d70ca0c4f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:18:03 +0200 Subject: [PATCH 31/32] feat(subgroups): say which group the parent is, not only which id The parent card on a room's detail screen read "Parent group" over sixteen hex characters. Every other card naming a room names it -- `SubgroupCard` has shown `room?.chatRoom?.subject` since subgroups landed -- and the one card pointing the other way up the lineage did not, although the room it points at is usually right there in the database. So: "Parent group" moves to the overline, the group's name becomes the headline, and the truncated id moves underneath, where it can still be read against what the lineage says. The label keeps its place rather than being replaced, because a card carrying a name and nothing else says nothing about which way the lineage runs, and the signing key's card -- same icon column, same shape -- sits directly above this one. **The name can only come from the room, and there is not always one.** A birth certificate names the child and never the certifier, so a member of the subgroup who is not also in the group above holds a verified lineage with nothing to call it by. That case keeps the card it already had: the id as the headline, no supporting line. It is not a corner -- it is the ordinary position of anyone welcomed into a subgroup rather than founding one -- so the room is a nullable field rather than something to wait for. `SubgroupManager.parentGroupsOf` is `parentsOf` with each id looked up, returning `Parent(chatRoomId, room)` the way `subgroupsOf` returns `Subgroup`. The verified set and its canonical order are untouched; a parent whose room is missing stays in the list. The id-only `parentsOf` stays as it is, because `offersTo` compares ids and should not pay for rooms it will not read. Three tests: a parent this device is in is named by its room, one it is not in is still listed with nothing to name it, and two parents keep the lineage's own order -- asserted against `parentsOf` rather than a written-out order, since canonical means sorted by id and nobody should have to know how two derived ids sort. 459 common tests, 985 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseChatRepository.kt | 4 +- .../compose/managers/SubgroupManager.kt | 29 +++++++++ .../compose/repository/ChatRepository.kt | 10 +++- .../ui/composable/ChatRoomDetailScreen.kt | 59 ++++++++++++++----- .../ui/view/model/ChatRoomDetailViewModel.kt | 2 +- .../ui/view/state/ChatRoomDetailUIState.kt | 7 ++- .../managers/SubgroupManagerJvmTest.kt | 48 +++++++++++++++ 7 files changed, 138 insertions(+), 21 deletions(-) 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 3463a4e5..70e05e89 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 @@ -192,8 +192,8 @@ class DatabaseChatRepository( false } - override suspend fun parentsOf(chatRoomId: String): List = try { - SubgroupManager.parentsOf(database, chatRoomId) + override suspend fun parentsOf(chatRoomId: String): List = try { + SubgroupManager.parentGroupsOf(database, chatRoomId) } catch (e: Throwable) { logger.e("Error reading the parents of $chatRoomId", e) emptyList() 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 a10c237c..7ca84bcf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -842,6 +842,35 @@ object SubgroupManager { database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() .orEmpty() + /** + * A group this room is a child of, plus the room itself where this device + * has it. + * + * [room] is the only place a parent's *name* can come from: a certificate + * names the child, never the certifier, so a device that is not in the + * parent room knows the lineage and not what to call it. Null is therefore + * the ordinary case for a subgroup member who is not also a parent member, + * and the id is all such a screen can honestly show. + */ + data class Parent( + val chatRoomId: HexKey, + val room: LocalChatRoom?, + ) + + /** + * [parentsOf], with each parent's room where this device holds one. + * + * The same verified set in the same order -- this only looks each id up, and + * a parent whose room is missing stays in the list rather than being dropped. + */ + suspend fun parentGroupsOf(database: MantraDatabase, chatRoomId: String): List = + parentsOf(database, chatRoomId).map { parentChatRoomId -> + Parent( + chatRoomId = parentChatRoomId, + room = database.chatRoomDao().findChatRoomById(parentChatRoomId) + ) + } + /** * What a certificate arriving in a room is, or null if it is nothing this * room should believe. 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 319e3782..222cdea5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -78,8 +78,12 @@ interface ChatRepository { * may be adopted by another later. A member welcomed in after the founding * holds no state and gets an empty list even when the room really is a * subgroup. + * + * Each carries the parent's room where this device has one, which is the + * only place its name can come from -- a certificate names the child and + * never the certifier. */ - suspend fun parentsOf(chatRoomId: String): List + suspend fun parentsOf(chatRoomId: String): List /** * One event the group signed, as the group signed it, or null when this @@ -323,7 +327,9 @@ interface ChatRepository { override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() - override suspend fun parentsOf(chatRoomId: String): List = emptyList() + override suspend fun parentsOf( + chatRoomId: String + ): List = emptyList() override suspend fun groupSignedEvent( chatRoomId: String, 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 9125dfbb..f3bcf1ea 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 @@ -280,15 +280,40 @@ fun ChatRoomDetailScreen( // later. They are a set rather than a path -- neither // is "the" parent and the order says nothing about // which came first -- so each gets an equal row. - chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId -> - item(key = "parent-$parentChatRoomId") { + chatRoomDetailUIState.parents.forEach { parent -> + item(key = "parent-${parent.chatRoomId}") { + // The parent's own name, which only its room + // can give: a certificate names the child and + // never the certifier, so a member of the + // subgroup who is not also in the parent has + // the id and nothing else to call it by. + val name = parent.room + ?.chatRoom + ?.subject + ?.takeIf { it.isNotBlank() } + + // Shown either way -- as the headline where + // there is no name, and under it where there + // is, since it is the part that can be + // compared against what the lineage says. + val identifier: @Composable () -> Unit = { + Text( + text = parent.chatRoomId + .take(16) + .inComparableGroups(), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + Card( modifier = Modifier.fillMaxWidth(), onClick = { onNavigateToRoute.invoke( ChatRoomDetailRoute( activeUserPublicKey = activeUserPublicKey, - chatRoomId = parentChatRoomId, + chatRoomId = parent.chatRoomId, relayHint = relayHint ) ) @@ -307,19 +332,25 @@ fun ChatRoomDetailScreen( contentDescription = Decorative ) }, - headlineContent = { + // The label keeps its place above the + // name rather than being replaced by + // it: a card carrying a group's name + // and nothing else says nothing about + // which way the lineage runs, and the + // signing key's card -- same icon + // column, same shape -- sits directly + // above this one. + overlineContent = { Text(text = stringResource(Res.string.parent_group)) }, - supportingContent = { - Text( - text = parentChatRoomId - .take(16) - .inComparableGroups(), - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis - ) - } + headlineContent = { + if (name != null) { + Text(text = name) + } else { + identifier() + } + }, + supportingContent = identifier.takeIf { name != null } ) } } 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 6b7c16f3..8b79af88 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 @@ -82,7 +82,7 @@ class ChatRoomDetailViewModel( groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), - parentChatRoomIds = chatRepository.parentsOf(chatRoomId), + parents = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId), 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 d357797e..9b976403 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 @@ -41,15 +41,18 @@ sealed interface ChatRoomDetailUIState { */ val subgroups: List = emptyList(), /** - * The group this room is a subgroup of, or null when it is nobody's + * The groups this room is a subgroup of, empty when it is nobody's * child. * * Verified: it comes off the room's key state, which only carries a * parent whose certificate was checked. A member welcomed in after the * founding holds no state and so sees nothing here, even in a room that * really is a subgroup. + * + * Each carries the parent's room where this device holds one, so the + * card can name the group rather than only identify it. */ - val parentChatRoomIds: List = emptyList(), + val parents: List = emptyList(), /** * Whether this device could open a subgroup here at all. * 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 32ee1bd3..ca78097c 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -777,6 +777,54 @@ class SubgroupManagerJvmTest { Unit } + @Test + fun `a parent this device is in is named by its room`() = runBlocking { + parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId)) + + val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single() + + assertEquals(parentRoomId, parent.chatRoomId) + // The room, and nothing else, is where a parent's name comes from: a + // certificate names the child and never the certifier. + assertEquals("Ekklesia", parent.room?.chatRoom?.subject) + } + + @Test + fun `a parent this device is not in is still listed, with nothing to name it`() = runBlocking { + // No parent room: the ordinary position of a subgroup member who is not + // also in the group above. The lineage is known -- it was checked + // against that group's own signature -- so the parent stays in the list + // and only its name is missing. + childWithKeyState(parents = listOf(parentRoomId)) + + val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single() + + assertEquals(parentRoomId, parent.chatRoomId) + assertNull(parent.room) + } + + @Test + fun `two parents keep the lineage's own order, named or not`() = runBlocking { + parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId, siblingRoomId)) + + val parents = SubgroupManager.parentGroupsOf(db, childRoomId) + + // The same set in the same order -- canonical, which is sorted by id and + // so says nothing about which parent came first. Asserted against + // `parentsOf` rather than against a written-out order, since the ids are + // derived from keys and nobody should have to know how two of them sort. + assertEquals( + SubgroupManager.parentsOf(db, childRoomId), + parents.map { it.chatRoomId } + ) + assertEquals( + mapOf(parentRoomId to "Ekklesia", siblingRoomId to null), + parents.associate { it.chatRoomId to it.room?.chatRoom?.subject } + ) + } + private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? = SubgroupManager.refuseAdoption( database = db, From aff1e330a0bdc2947a3916aa70b053d95a6cec7c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:21:49 +0200 Subject: [PATCH 32/32] fix(repository): a no-op answers all sixty calls, and a test keeps it total Sixty `TODO("Not yet implemented")` bodies sat across four no-op repositories -- 41 of 45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on `MarmotRepository`, 1 of 1 on `SearchRepository`. f46a7c62 fixed the one that was firing and flagged the rest for their own change; this is that change. **Why a stub here is not one test's problem.** These are reached from view models that call their repository inside `viewModelScope.launch(Dispatchers.IO)`, a coroutine nobody awaits. The `NotImplementedError` escapes to the platform handler, where kotlinx-coroutines-test's `ExceptionCollector` stashes it in `unprocessedExceptions` -- a `runBlocking` test has no `TestScope` registered to take it -- and the *next* `runTest` drains the stash and rethrows it as `UncaughtExceptionsBeforeTest`, under its own name, for a call it never made. Reproduced before touching anything: `jvmTest --rerun-tasks` failed `NavigationRoutingTest > an account already on the device does not stay on the loading screen` with four suppressed `NotImplementedError`s, each naming `NostrRepository.kt:376` from `ChatMessageListViewModel.kt:366`. Seventeen jvmTest files build on the Nostr one alone. **Inert, not loud -- for every member.** The question per member was whether a silent body or a deliberate throw is right, and the callers settle it: the only two populations are `@ConformancePreviews` composables in commonMain and test fixtures, and both want a repository that has no data rather than one that refuses. A preview that throws is a broken preview. `NO_OP_DKG_REPOSITORY` and `NO_OP_FROST_SIGNING_REPOSITORY` already work this way, so this follows that idiom instead of inventing a second one. **Throwing is never the mechanism for being loud.** Keeping a throw for members that "should not be called" was the alternative, and it is wrong twice: it re-opens exactly this bug the next time such a member is reached from an unawaited launch, and it puts the assertion in the fixture instead of the test. A member a test must not call is that test's assertion to make -- override it with a recorder and assert it stayed untouched. **A callback is not invoked.** `onCompletion`, `onSuccess` and `onError` are left alone by `createNewProfile`, `createNewTextNote`, `leaveChatRoom`, `softDeleteChatRoom` and `deleteChatRoom`. Calling `onCompletion` would say work happened that did not, which is how a test comes to pass on the fixture rather than on the code; a test that wants a branch fired overrides the member and fires the one it is testing. **A flow emits its empty value once, rather than never.** `flowOf(null)` and `flowOf(emptyList())` throughout, and `ChatRepository`'s two existing `flow { }` stubs converted with them. An empty flow leaves a screen in Loading for ever; one emission lets it render its empty state, which is what a preview is for. **`createMlsDirectMessage` is the one that cannot say "nothing".** It owes the id of a room and `String` has no absent value, so it returns `""`, documented as such. The signature is the real oddity -- both creators beside it can report failure (`createNip17ChatRoom` returns null, `createMarmotGroup` an `Outcome`) -- but changing it would touch `DatabaseChatRepository` and its caller, so it is flagged in a comment rather than widened here. Nothing reaches it in practice: `initiateNewChat` calls it only once a profile and a key package have both resolved, and against this object neither ever does. **Nothing was passing vacuously, checked rather than assumed.** The risk in a blanket sweep is a test that currently fails honestly starting to pass on an empty body. No test anywhere asserts `NotImplementedError`, and the suite went from 982 tests with 1 failure to 983 with 0, with nothing newly failing -- including across the `flow { }` change and the callback decision. **The guard discovers the singletons rather than listing them.** `NoOpRepositoryTotality` reads the `repository/` directory, keeps every file declaring a `NO_OP_` val, and reports each `TODO(` in code with its file, line and member -- comments excluded, since the contract on `NO_OP_NOSTR_REPOSITORY` names `TODO()` in its own prose. A hand-written list was the first attempt and was already wrong: it named six files when the tree holds eight, missing `MantraRepository` and `CachingImportRepository`, both total by luck rather than cover. A floor of 8 goes with it, in the spirit of `m3-audit.sh`'s floors, because the way a source-reading test fails silently is by finding nothing and passing. Both halves were mutation-tested: a reintroduced `TODO()` fails naming `MarmotRepository.kt:49` and its member, and a floor of 99 reports all eight files found. **Calling the members for real was the alternative, and tells you less.** Sixty live calls need a fixture for every parameter type, and a reflective sweep cannot tell a body that threw from an argument it could not build -- so a failure would not distinguish the bug from the harness. The invariant is a source-level one, so it is checked there. **f46a7c62's narrow override is removed.** With the no-op total, the `object : NostrRepository by ...` in `CeremonyQueueRoutingJvmTest` is dead weight, and its comment had become false -- it asserted the member is "one of the forty-one `TODO()` bodies". Replaced with the bare no-op and a past-tense note pointing at the guard. **Not done here: the reporting mechanism.** The open question was whether the suite should fail fast on an uncaught coroutine exception, or name the right test. Tracing kotlinx-coroutines-test 1.11 shows the culprit's stack trace is already preserved, as a suppressed cause on the `UncaughtExceptionsBeforeTest` -- nothing is lost, it is only filed under the wrong name, and Gradle's console shows just the top line. A handler that re-printed it would add noise, not information, so none was added; the missing name is what this commit supplies. `MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE` is also still misnamed -- it is a repository, not a bundle -- and left for its own change. 1171 jvm tests, 0 failed, under `--rerun-tasks`, on the merged tree. m3Audit clean, `:composeApp:compileDebugKotlinAndroid` green. Co-Authored-By: Claude Opus 5 --- .../compose/repository/ChatRepository.kt | 87 ++++--- .../compose/repository/MarmotRepository.kt | 42 ++-- .../compose/repository/NostrRepository.kt | 234 ++++++++---------- .../compose/repository/SearchRepository.kt | 15 +- .../NoOpRepositoryTotalityJvmTest.kt | 138 +++++++++++ .../view/model/CeremonyQueueRoutingJvmTest.kt | 26 +- 6 files changed, 319 insertions(+), 223 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt 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 319e3782..6336bc24 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -19,7 +19,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf interface ChatRepository { suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> @@ -308,7 +308,13 @@ interface ChatRepository { ): MarmotReindexReport companion object { - val NO_OP_CHAT_REPOSITORY = object: ChatRepository { + /** + * A chat repository with no database behind it: every room is absent, every + * membership change is refused with a reason, and no member throws. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure, and for the rules a new member follows. + */ + val NO_OP_CHAT_REPOSITORY: ChatRepository = object : ChatRepository { override suspend fun requestGroupHistoryIfMissing( chatRoomId: String, userPublicKey: HexKey @@ -338,9 +344,8 @@ interface ChatRepository { coordinatorPublicKey: HexKey, ): String? = "No database" - override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { - return flow { } - } + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> = + flowOf(emptyList()) override suspend fun getChatRoomListByPublicKey(publicKey: String): List { return emptyList() @@ -375,22 +380,18 @@ interface ChatRepository { userPublicKey: HexKey ): Boolean = false - override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { - TODO("Not yet implemented") - } + override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List = + emptyList() - override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? { - TODO("Not yet implemented") - } + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? = + null override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List { return emptyList() } - override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> { - return flow { - } - } + override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> = + flowOf(emptyList()) override suspend fun getOrCreateChatRoom( chatRoomId: String, @@ -407,13 +408,11 @@ interface ChatRepository { return null } - override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? { - TODO("Not yet implemented") - } + override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? = + null - override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow = + flowOf(null) override suspend fun createNip17ChatRoom( userPublicKey: HexKey, @@ -424,23 +423,30 @@ interface ChatRepository { return null } + /** + * The empty id, meaning no room was created. + * + * The only member here whose return type cannot say "nothing": it owes the id + * of a room, and `String` has no absent value. Both creators beside it can say + * they failed -- [createNip17ChatRoom] returns null, [createMarmotGroup] an + * [MarmotGroupCreation.Outcome] -- so this one's signature is the odd one out + * rather than this body being the odd answer. Nothing reaches it in practice: + * `ChatRoomMessagingViewModel.initiateNewChat` calls it only once a profile and + * a key package have both resolved, and against this object neither ever does. + */ override suspend fun createMlsDirectMessage( name: String?, description: String?, userPublicKey: HexKey, peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage - ): String { - TODO("Not yet implemented") - } + ): String = "" override suspend fun inviteMember( localChatRoom: LocalChatRoom, peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun addMembers( localChatRoom: LocalChatRoom, @@ -464,45 +470,36 @@ interface ChatRepository { localChatRoom: LocalChatRoom, messageType: Kind, directMessageRecipientPublicKey: HexKey? - ) { - // TODO("Not yet implemented") - } + ) = Unit override fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? { return null } - override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow> = + flowOf(emptyList()) override suspend fun sealGiftWrapPayload( giftWrapPayload: GiftWrapPayload, nostrSignerSync: NostrSignerSync - ) { - TODO("Not yet implemented") - } + ) = Unit + // None of the three calls onCompletion: nothing was left or deleted, and each + // of these callbacks navigates away from a room that is still there. override suspend fun leaveChatRoom( localChatRoom: LocalChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun softDeleteChatRoom( chatRoom: ChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun deleteChatRoom( chatRoom: ChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun reindexMarmotGroupEvents( chatRoomId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt index c9875665..0f9af0d5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt @@ -5,6 +5,7 @@ import press.mantra.compose.database.model.MarmotKeyPackageBundle import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf interface MarmotRepository { @@ -33,40 +34,35 @@ interface MarmotRepository { suspend fun encryptAndSendMarmotInnerEvent(marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync) companion object { - val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository { + /** + * A repository with no key packages behind it: inert, and no member throws. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure, and for the rules a new member follows. + */ + val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE: MarmotRepository = object : MarmotRepository { override suspend fun publishMarmotKeyPackageBundle( publicKey: HexKey, nsecPassword: String - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) { - TODO("Not yet implemented") - } + override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) = Unit - override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List { - return emptyList() - } + override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List = + emptyList() - override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow = + flowOf(null) override suspend fun encryptAndSendMarmotInnerEvent( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync - ) { - TODO("Not yet implemented") - } + ) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 4e8a9805..0a8d6432 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -20,6 +20,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf interface NostrRepository { suspend fun getLocalAccounts(): List @@ -209,78 +210,97 @@ interface NostrRepository { suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) companion object { - val NO_OP_NOSTR_REPOSITORY = object : NostrRepository { - override suspend fun getLocalAccounts(): List { - TODO("Not yet implemented") - } + /** + * A repository with nothing behind it: every question answers "nothing here", + * every write goes nowhere, and **no member throws**. + * + * Previews and test fixtures are the only callers, and both want inertness. It is + * written out here because these members were once `TODO()`, and a `TODO()` in a + * no-op is not a loud failure -- it is a silent misattribution. Callers reach most + * of them from a `viewModelScope.launch(Dispatchers.IO)` that nobody awaits, so the + * `NotImplementedError` escapes to the platform handler, is stashed by + * kotlinx-coroutines-test's `ExceptionCollector`, and is re-thrown as + * `UncaughtExceptionsBeforeTest` inside *the next* `runTest` -- under that test's + * name, with the real culprit buried as a suppressed cause. One `TODO()` here + * (`queueNegentropySynchronizeRequest`, reached from + * `ChatMessageListViewModel.scheduleSynchronization`) failed `NavigationRoutingTest` + * four times a run for a call that test never made. + * + * So the rules for a member added here, which the other three no-op repositories + * follow too: + * + * - **Answer the call, never stub it out.** `NoOpRepositoryTotalityJvmTest` reads + * these four objects and fails if an unimplemented stub reappears in one. + * - **A flow emits its empty value once** -- `flowOf(null)`, `flowOf(emptyList())` -- + * rather than `flow { }`. A screen collecting an empty flow waits in Loading + * forever; one collecting `flowOf(null)` renders its empty state, which is what + * a preview is for. + * - **A callback is not invoked.** Nothing happened, and calling `onCompletion` or + * `onSuccess` would say it did -- letting a test pass on the fixture rather than + * on the code under test. + * - **Throwing is never the way to be loud.** A member that a test must not call is + * the test's assertion to make, not the fixture's: override it with a recorder and + * assert it stayed untouched. An exception thrown here lands on whichever test + * runs next. + * + * A test that needs a real answer from one member overrides just that one: + * `object : NostrRepository by NO_OP_NOSTR_REPOSITORY { … }`. + */ + val NO_OP_NOSTR_REPOSITORY: NostrRepository = object : NostrRepository { + override suspend fun getLocalAccounts(): List = emptyList() - override suspend fun observeLocalAccount(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = + flowOf(null) - override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? { - TODO("Not yet implemented") - } + override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? = null - override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = + flowOf(null) override suspend fun observeRelation( publicKey: HexKey, activePublicKey: HexKey - ): Flow> { - TODO("Not yet implemented") - } + ): Flow> = flowOf(emptyList()) - override suspend fun deleteConnection(connection: Connection) { - TODO("Not yet implemented") - } + override suspend fun deleteConnection(connection: Connection) = Unit - override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun observePendingBroadcastNostrEventRequests(): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingBroadcastNostrEventRequests(): Flow = + flowOf(null) override suspend fun reopenInbox(activeKeyPair: KeyPair): Int = 0 - override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow = + flowOf(null) - override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow = + flowOf(null) + // onCompletion is deliberately not called: no profile was created, and a + // fixture that reports completion is a fixture a test can pass against. override suspend fun createNewProfile( publicKey: HexKey, name: String?, biography: String?, onCompletion: suspend () -> Unit - ) { - TODO("") - } + ) = Unit - override suspend fun signInToProfile(publicKey: HexKey) { - TODO("Not yet implemented") - } + override suspend fun signInToProfile(publicKey: HexKey) = Unit override suspend fun setUpProfileForExistingKey( publicKey: HexKey, placeholderUnsignedNostrEventId: Long, name: String?, biography: String?, - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun forgetLocalAccount(publicKey: HexKey) { - TODO("Not yet implemented") - } + override suspend fun forgetLocalAccount(publicKey: HexKey) = Unit + // Neither onSuccess nor onError: the note was not written, and it did not fail + // to be written either -- there was nowhere to write it. A test that wants + // either branch overrides this member and calls the one it is testing. override suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -289,74 +309,50 @@ interface NostrRepository { quotedNostrEvent: LocalNostrEvent?, onError: () -> Unit, onSuccess: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? { - return null - } + override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? = + null - override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List) { - TODO("Not yet implemented") - } + override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List) = Unit - override suspend fun wipeDatabase() { - } + override suspend fun wipeDatabase() = Unit override suspend fun publishNostrEvent( unsignedNostrEvent: UnsignedNostrEvent, nostrEvent: NostrEvent, relayURLs: List, activeKeyPair: KeyPair - ) { - - } + ) = Unit override suspend fun broadcastProcessed( broadcastNostrEventRequest: BroadcastNostrEventRequest, status: String - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun requeueStaleBroadcastNostrEventRequests(): Int { - TODO("Not yet implemented") - } + override suspend fun requeueStaleBroadcastNostrEventRequests(): Int = 0 - override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { - TODO("Not yet implemented") - } + override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit - override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { - TODO("Not yet implemented") - } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit - override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) { - TODO("Not yet implemented") - } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) = Unit - override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) { - TODO("Not yet implemented") - } + override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, synchronizeNostrEventRequest: SynchronizeNostrEventRequest, synchronizationRelayURLs: List, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, negentropySynchronizeRequest: NegentropySynchronizeRequest, synchronizationRelayURLs: List, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, @@ -364,82 +360,54 @@ interface NostrRepository { synchronizationRelayURLs: List, level: Int, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { - TODO("Not yet implemented") - } + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) = Unit - override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { - TODO("Not yet implemented") - } + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) = Unit - override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow = + flowOf(0) override suspend fun getNostrEvent( publicKey: HexKey, kind: Kind - ): NostrEvent? { - TODO("Not yet implemented") - } + ): NostrEvent? = null - override suspend fun observeNostrFeed(): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeNostrFeed(): Flow> = flowOf(emptyList()) - override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> = + flowOf(emptyList()) override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean - ): List { - TODO("Not yet implemented") - } + ): List = emptyList() - override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow = + flowOf(null) - override suspend fun searchableProfiles(excludingPublicKeys: Array): List { - TODO("Not yet implemented") - } + override suspend fun searchableProfiles(excludingPublicKeys: Array): List = + emptyList() - override suspend fun filterProfiles(searchTerm: String): List { - TODO("Not yet implemented") - } + override suspend fun filterProfiles(searchTerm: String): List = emptyList() - override suspend fun observeProfileWithFollowers(publicKey: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithFollowers(publicKey: String): Flow = + flowOf(null) - override suspend fun observeProfileWithFollowing(publicKey: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithFollowing(publicKey: String): Flow = + flowOf(null) - override suspend fun getRecentSearches(): List { - TODO("Not yet implemented") - } + override suspend fun getRecentSearches(): List = emptyList() - override suspend fun removeRecentSearch(recentSearch: RecentSearch) { - TODO("Not yet implemented") - } + override suspend fun removeRecentSearch(recentSearch: RecentSearch) = Unit override suspend fun saveSearchQuery( query: String, synchronizationFilterArray: press.mantra.compose.database.model.typealiases.SynchronizationFilterArray? - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) { - TODO("Not yet implemented") - } + override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt index 71aeb2b3..4e07e9d3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt @@ -7,10 +7,15 @@ interface SearchRepository { ) companion object { - val NO_OP_SEARCH_REPOSITORY = object: SearchRepository { - override suspend fun submitSearch(synchronizeNostrEventRequests: List) { - TODO("Not yet implemented") - } + /** + * A search that reaches no relay: inert, and the one member does not throw. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure. + */ + val NO_OP_SEARCH_REPOSITORY: SearchRepository = object : SearchRepository { + override suspend fun submitSearch( + synchronizeNostrEventRequests: List + ) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt new file mode 100644 index 00000000..d719af03 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.repository + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Every member of every `NO_OP_*` repository answers its call. None is left stubbed out. + * + * Every one in `commonMain/.../repository/`, found by reading the directory rather than by + * naming files -- see [declaration]. + * + * The rule is worth a test of its own because breaking it does not fail where it is broken. + * A `TODO()` in one of these objects throws `NotImplementedError`, and the callers are view + * models that reach their repository from `viewModelScope.launch(Dispatchers.IO)` -- a + * coroutine nobody awaits. The error escapes to the platform handler, where + * kotlinx-coroutines-test's `ExceptionCollector` stashes it (`unprocessedExceptions`, because + * a `runBlocking` test has no `TestScope` registered to take it), and the *next* `runTest` to + * start drains the stash and rethrows it as `UncaughtExceptionsBeforeTest` -- under that + * test's name, for a call it never made. The real stack trace survives only as a suppressed + * cause several frames down. + * + * That cost a debugging session once: `queueNegentropySynchronizeRequest` was a `TODO()`, + * `CeremonyQueueRoutingJvmTest` reached it through `ChatMessageListViewModel.initiate()`, and + * `NavigationRoutingTest > an account already on the device does not stay on the loading + * screen` failed four times a run while the test that caused it passed. Seventeen test files + * build on `NO_OP_NOSTR_REPOSITORY` alone, so one stub is never one test's problem. + * + * So this is checked at the only altitude where it can be checked cheaply and totally: the + * source. Calling all sixty-odd members for real would mean a fixture for every parameter + * type, and a test that fails because a fixture was wrong tells you less than this one does. + * A reflective sweep has the same problem -- it cannot tell a body that threw from an argument + * it could not build. + * + * It fails under its own name, which is the whole point. + */ +class NoOpRepositoryTotalityJvmTest { + + /** + * Every file that declares one, found rather than listed. + * + * A list would be wrong the first time a ninth repository is added -- and was: the + * first version of this test named six files when the tree held eight, having missed + * `MantraRepository` and `CachingImportRepository` (both total, which is luck rather + * than cover). Discovery is the point of the test, so it discovers. + */ + private val declaration = Regex("""\bval\s+NO_OP_[A-Z0-9_]+""") + + /** + * A floor, in the spirit of `docs/scripts/m3-audit.sh`: the way this test breaks + * silently is by finding nothing to check -- a moved package, a renamed convention -- + * and then passing. Eight is the count on 2026-10-02. Raise it when one is added. + */ + private val knownSingletons = 8 + + @Test + fun `no no-op repository leaves a member unimplemented`() { + val directory = repositoryDirectory() + + val sources = (directory.listFiles() ?: emptyArray()) + .filter { it.isFile && it.name.endsWith(".kt") } + .map { it.name to it.readText() } + .filter { (_, source) -> declaration.containsMatchIn(source) } + .sortedBy { (name, _) -> name } + + assertTrue( + sources.size >= knownSingletons, + "Found ${sources.size} no-op repositories in ${directory.path}, expected at " + + "least $knownSingletons. Either one was removed -- lower the floor in the " + + "same change -- or this test has stopped finding them and is passing on " + + "nothing. Found: ${sources.map { it.first }}" + ) + + val offences = sources.flatMap { (name, source) -> stubbedMembers(name, source) } + + assertEquals( + emptyList(), + offences, + "A no-op repository member is stubbed out. Give it the answer a repository with " + + "nothing behind it would give -- null, an empty collection, flowOf(...) of " + + "one of those, Unit, 0, false -- and never TODO(). See the contract on " + + "NostrRepository.NO_OP_NOSTR_REPOSITORY. Stubs found:\n" + + offences.joinToString("\n") { " $it" } + ) + } + + /** + * `file:line member` for every `TODO(` in code, comments excluded -- the contract on + * `NO_OP_NOSTR_REPOSITORY` names `TODO()` in its prose, and a doc comment explaining the + * rule must not trip the check that enforces it. + */ + private fun stubbedMembers(name: String, source: String): List { + val found = mutableListOf() + var inBlockComment = false + var member = "(before the first declaration)" + + source.lines().forEachIndexed { index, raw -> + var line = raw + if (inBlockComment) { + val end = line.indexOf("*/") + if (end < 0) return@forEachIndexed + line = line.substring(end + 2) + inBlockComment = false + } + val blockStart = line.indexOf("/*") + if (blockStart >= 0) { + inBlockComment = !line.substring(blockStart).contains("*/") + line = line.substring(0, blockStart) + } + val lineComment = line.indexOf("//") + if (lineComment >= 0) line = line.substring(0, lineComment) + + if (line.contains("fun ")) member = line.trim() + if (line.contains("TODO(")) found += "$name:${index + 1} $member" + } + return found + } + + /** + * Found by walking up from the working directory rather than taken from it, because the + * one is the `composeApp` project directory under `:composeApp:jvmTest` and the other is + * the repository root when the same test is run from the root project. A test about + * things that fail silently does not get to pass because it could not find its input. + */ + private fun repositoryDirectory(): File { + val relative = "composeApp/src/commonMain/kotlin/press/mantra/compose/repository" + var directory: File? = File("").absoluteFile + while (directory != null) { + val candidate = File(directory, relative) + if (candidate.isDirectory) return candidate + directory = directory.parentFile + } + throw AssertionError( + "Could not find $relative above ${File("").absolutePath} -- this test read nothing." + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt index faaec773..5093b1be 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.delay import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession -import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalParticipant @@ -152,23 +151,16 @@ class CeremonyQueueRoutingJvmTest { val viewModel = ChatMessageListViewModel( initialChatMessageListUIState = ChatMessageListUIState.Loading, localChatRoom = room(), - // Not the bare no-op. `initiate` calls `scheduleSynchronization` on - // its second line, which launches on `Dispatchers.IO` and -- because + // The bare no-op is safe again. `initiate` calls `scheduleSynchronization` + // on its second line, which launches on `Dispatchers.IO` and -- because // `room()` gives every participant a null profile, so there is always - // somebody it cannot name -- reaches - // `queueNegentropySynchronizeRequest`. That member is one of the - // forty-one `TODO("Not yet implemented")` bodies on - // NO_OP_NOSTR_REPOSITORY, so it throws in a coroutine nobody awaits. - // - // Nothing catches it, so it reaches the default handler, and the next - // `runTest` in the same JVM reports it as `UncaughtExceptionsBeforeTest` - // -- against its own name. That is how this test failed - // `NavigationRoutingTest` four times a run while passing itself. - nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { - override suspend fun queueNegentropySynchronizeRequest( - negentropySynchronizeRequests: List - ) = Unit - }, + // somebody it cannot name -- reaches `queueNegentropySynchronizeRequest`. + // That member used to be one of forty-one `TODO()` bodies on + // NO_OP_NOSTR_REPOSITORY and threw into whichever `runTest` ran next, + // which is how this test failed `NavigationRoutingTest` four times a run + // while passing itself. The override that stood here is gone because the + // no-op is total now; `NoOpRepositoryTotalityJvmTest` keeps it that way. + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {