Files
mantra-kmp/composeApp/src/commonMain/composeResources/values/strings.xml

485 lines
41 KiB
XML
Raw Normal View History

2026-06-15 14:39:46 +02:00
<resources>
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
<!--
The app's own string catalogue. Nearly empty, for now.
This file previously held 1101 strings inherited from the phoenix wallet fork
(notification channel titles, electrum server settings, swap timeouts), and
app_name was "Machankura". Nothing referenced any of them: the only two
stringResource calls in the tree were both commented out, and one of them named an
R.string, which does not exist in a Compose Multiplatform resource set at all.
Keeping 1101 dead strings made this look like the app's catalogue while the app's
actual 334 strings sat inside composables.
The launcher label is a separate resource, in androidMain/res/values/strings.xml,
feat(groups): the queue of a list the group curates, read off the relays it named `930d37c8` gave a group the schema: the definition of a list, signed by the room's own key, for strangers to answer. This is the answers. Under every schema card on the group's screen there is now a "View suggestions" button, and it opens the list's *queue* -- every kind 31888 anyone has published in reply to the schema's coordinate, newest first, whoever signed it, with a mark on each one the group has already taken up into the list. It is the second half of the curated-list NIP in this app, the read side of it: suggestions (31888) and canonical entries (31890) are now read and checked here. They are still not written; that is the third half. The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that site's one list: one row per suggestion event rather than one per film, so that a reader can see what is waiting before anything is added and whether the curator has taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the reference for everything the screen decides -- one row per event, the newest version per coordinate, "Curated" where a canonical entry shares the `d`, and a timer standing in for an answer that never comes. **The button is behind no gate, and it is per card.** Every other button in the identity block is hidden from a member holding no share of the key, because each of them proposes a signature by the group. The queue is the one thing in the block the group did not write. A schema exists to be answered by strangers, and reading the answers takes no share of anything, so the button is there for whoever is looking -- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a schema and finds it. It sits under its own card rather than once for the section, in the same `item {}` as the card, because each list has its own queue and a button between two cards would say nothing about which one it opened. **The protocol is ported rule for rule, and the fixtures are the NIP's own.** `nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`, `verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind is the one the schema names; every required field is present; no field without `repeat` appears twice; every value matches its field's type and config; every `require-any` group is met; the `a` root names this schema and no other; and the author is somebody the visibility admits. A canonical entry gets two rules on top -- its author is the schema's author, because curation is the one power a schema does not delegate, and a source pointer, when there is one, is a well-formed suggestion coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest` takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's example, and the curator's sign-off on it, and checks both against the bitcoin.mov schema from the same document; then the doc's own minimum suggestion, which has an IMDb link and no watch link and satisfies `require-any` that way; then every rejection the doc lists and says why it matters -- an unknown type, a year outside 1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a `d` with a space in it -- plus the reply rules, the repeat rule, the closed-list rule and the two canonical rules. **Rejected, not repaired, and read by field.** The NIP says a client MUST verify an entry against its schema and MUST reject one that does not satisfy it rather than showing it with the bad parts blanked out; relays carry malformed, partial and hostile events from other applications. So `suggestion` and `canonical` return the entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema. problems` is an enum rather than a sentence -- so that a form later can show them inline. What comes back is a `CuratedEntry` keyed by the schema's *field names* rather than by tag, because a field is what a form and a screen know an entry by and the tag is only where it was kept: the two `r` tags of the example land on `watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no field of the NIP's schema and are not in the reading -- "tags the schema does not define MAY be present and MUST be ignored". **A pattern this platform cannot compile counts as not matched.** The reference builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring an uncompilable pattern would accept entries the site refuses; refusing them is the conservative reading, and it is documented at the check. **A coordinate splits on the first two colons only.** A `d` may itself contain colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses -- so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`, and the test pins the identifier surviving its own colon and the pubkey being lowercased on the way through. **The queue is a reading over stored rows, with three things to close and one to add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the queue out of it. Closed: an event that does not satisfy the schema; a kind 31890 signed by anybody but the group, which is somebody else's list and marks nothing; and an older version of an entry its author has since replaced, since both kinds are addressable and different relays hold different latest versions, so the merge that realises an edit happens here, newest per `kind:pubkey:d` with the event id breaking ties. Added: `isCurated`, true where a canonical entry the group signed shares the suggestion's identifier -- the coordinate both land on, and the reference page's rule. Newest first, because that is the order a queue is read in. **A stranger is named by their profile, and by their key until one arrives.** The indexer writes a placeholder profile named "LOADING..." the moment a pubkey is first seen, and a queue full of that would be a queue of nobody. `suggesterName` reads the profile only when it is a real one -- `createdAt` past `GENESIS_AT` -- and otherwise the short key, which is at least stable and distinguishing. The row's avatar is tinted from the key like every avatar here, so two suggestions by one stranger read as one stranger. **Read from where the schema says, and the screen says where.** The schema's `relay` tags are signed into the event for exactly this: a client that finds the list anywhere knows where its replies live and cannot be sent elsewhere by an unsigned config. A schema naming none -- the NIP allows it and lets a client pick -- is read from the group's general relay list, the *read* relays of it, since this is a read; and failing an agreed, non-empty one, from this build's own relay, the same fallback the schema editor seeds from. Spellings are normalised so one relay written two ways is one request. The header names the relays asked, because a queue read from the wrong relays and a list nobody has written to look exactly alike from here, and a member should be able to tell them apart. **Nothing here talks to a relay; it is a pull like every other one-off read.** `CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay for the sync pump to drain, carrying the NIP's two queries as one request's two filters -- everything anyone published in reply to the coordinate, and what the *group* published in reply to it, the second scoped by `authors` to spare the relay the work `queueOf` does regardless -- and watches the local table for what comes back. That watch needed a way to observe an arbitrary NIP-01 filter, which the repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes and falls back to text notes for the rest, and none of its shapes is "these kinds with this `a` tag". `observeNostrEventsMatching` applies the filter whole through `NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is anchored and escaped rather than a substring scan; the DAO method behind it names its observed tables explicitly, the events and the profiles joined onto them, so a row whose author's kind 0 arrives after the row does re-emits with the name filled in. What the screen shows is therefore what this device has *stored* for the coordinate -- which is also what it shows with no network at all, and what it showed last time until the relays answer again. `NostrEvent.toEvent` is the small conversion the reading needs to hand a stored row to a verifier written against the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the group's own. **"Still looking" is bounded by time, because nothing else bounds it.** The pump marks a request sent when the REQ goes out and processed when an event comes back. A relay holding nothing for the coordinate answers with an EOSE the pump does not record, so there is no signal for "asked and empty", and a screen that showed the local table's first, empty read would call every list empty for the second before its rows arrived -- which teaches a member not to believe it. So the queue is "being asked" from the request going out until either something lands or ten seconds pass, and only after that is an empty queue empty. The reference store does the same with a six-second timer; ten here because the request queues behind the pump's four subscription slots before it goes out. Rows arriving end it early, a late arrival after it is still shown, and `withQueue` -- the one state merge -- pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers "Ask again", which re-queues the same requests: the one thing the watch cannot do on its own is make more arrive. **The row shows what an entry is, not where to find it.** The screen is driven by a schema it has never seen, so what goes on a row is a decision rather than a layout: the title as the headline, since every list has one; a glance line of the form fields that say what the entry is, as `Label: value`, because a bare "2014 · en" says nothing to somebody who does not know the list's fields; the body where the list has one; and who suggested it, when. Links are left to the sheet -- a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it a member might act on: it says the group already took this up, and re-curating it would revise the entry rather than add one. **The sheet has the whole entry and the event it came as.** Every field the entry answered, labelled as the schema labels it and in the schema's order, so it reads as the form would have; the title not repeated, since it is the heading; tokens and links monospaced because those are compared and copied rather than read. Then the signed event, byte for byte, with a copy button -- the way the key state sheet shows the group's own statement and for the same reason: a prettier rendering is a different string to the one whose id was hashed. It is also the thing a canonical entry is built from, when that half arrives. **Four states, and the transition between them.** Loading while the room and the schema are read; an error, with nothing to retry, for a schema this device does not hold, since the identifier came from a navigation argument and reading it again fails the same way; and a loaded state that is either the rows, or looking, or empty with something to do. `ScreenStateTransition` wraps the `when`, which is the Scaffold's whole content. **The audit's proper-noun list grows by two films.** The preview suggests *The Rise and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and `m3-title-case.py` excludes sample data by name rather than by pattern. **Still nothing has reached a relay, and it bites once more.** Group-signed events are not yet published, so a group's schema has not been where a suggester could find it, and its queue is empty until it has. The screen reads the coordinate `31889:<group>:<d>` all the same and shows whatever a relay holds for it; the suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at the reading. 32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion and canonical entry against the NIP's schema: the reading by field, the minimum suggestion, the source pointer and its absence, curation not being delegated, a malformed source, the wrong kind, the reply rules, a missing required field, the repeat rule both ways, every value rule the doc lists, `require-any`, the closed and private lists, `checkValue` on durations, patterns and https, and the coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows: verified suggestions newest first with the broken, the foreign and the wrong-kind ones dropped, an edit collapsing to its newest version, the curated mark placed by the group's canonical entry and not a stranger's, and the naming of a suggester with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest` (6) covers the relay order and the read-only filter, one request per relay carrying both queries and the watch covering both kinds, the three outcomes of `withQueue`, an `initiate` run end to end against fakes -- the relays asked, the filter watched, the rows shown, the looking ended -- and a missing schema being an error rather than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header, the row's glance line with the link kept off it, the mark on the right row and only that row, looking against empty, and the sheet with its fields, its link and its event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the button under each card in turn and in front of a member with no share. 1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@1e52fc8f25e29017f1acb72697166ecdfe99f320
2026-09-12 12:54:34 +02:00
which is what AndroidManifest.xml points at. Both now say "Mantra".
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
Externalising the 334 is the rest of phase 4 in
docs/material-design-conformance.md. They land here.
Note for whoever edits this: XML forbids a double hyphen inside a comment, so use
an em dash or a comma. The compose resources plugin reports that only as
"XML file ... is not valid. Check the file content."
-->
<string name="app_name">Mantra</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="add_a_dialect_the_group_can_translate_into">Add a dialect the group can translate into</string>
<string name="add_artifact_to_library">Add artifact to library</string>
<string name="add_artifact_to_the_group_library">Add artifact to the group library</string>
<string name="add_chapter">Add chapter</string>
<string name="add_dialect">Add dialect</string>
<string name="add_to_group">Add to group</string>
<string name="add_translation">Add translation</string>
<string name="after_this_there_is_no_turning_back">After this there is no turning back.</string>
<string name="all_broadcasts_are_queued_so_that_we_can">All broadcasts are queued so that we can manage data usage on metered connections.</string>
<string name="any_amount">Any amount</string>
<string name="article">ARTICLE</string>
<string name="artifact_detail">Artifact detail</string>
<string name="as_long_as_you_control_your_keys_there_can">As long as you control your keys there can be no dispute about who YOU actually is.</string>
<string name="back">Back</string>
<string name="backup_confirmation">Backup confirmation</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="be_sure_to_keep_this_nsec_safe">Be sure to keep this nsec safe.</string>
<string name="be_the_first_to_comment">Be the first to comment.</string>
<string name="bio">Bio</string>
<string name="bip39_seed_with_the_standard_bip84">BIP39 seed with the standard BIP84 derivation path. The profile\'s nostr key comes off the same seed, so these 12 words restore both.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="block_user">Block user</string>
<string name="cancel">Cancel</string>
<string name="chapter_detail">Chapter detail</string>
<string name="chapter_name">Chapter name</string>
<string name="chapter_translation">Chapter translation</string>
<string name="chapters">Chapters</string>
<string name="choose_who_to_chat_with">Choose who to chat with</string>
<string name="close">Close</string>
<string name="cloud_backup">Cloud backup</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="copy_url">Copy URL</string>
<string name="could_not_unlock_your_phrase_please_try">Could not unlock your phrase. Please try again.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="country">Country</string>
<string name="create_chat">Create chat</string>
<string name="create_new_chat">Create new chat</string>
<string name="create_profile">Create profile</string>
<string name="create_project">Create project</string>
feat(marmot): put a # in front of every group's name, and retire (#admins) A device's room list holds two unrelated kinds of room and nothing on a row said which. A NIP-17 room is a conversation between the people in it. A Marmot room is a *group* -- an id its key derives, a membership baked into an MLS tree, admins who can act for it, a signature anyone holding the id can check -- and the two behave differently enough that guessing is a mistake. `"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half. Only the admin room got it; a subgroup got no marker at all, so as soon as a group had one child, half the Marmot rooms on the device were unmarked. It also sorted nowhere near the group it belonged to, and a truncated row drops a trailing suffix first -- so the marker was missing exactly where the list is crowded enough to need it. **The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every member is welcomed with, so a `#` added at display time would be a name this device alone could see. `#Ekklesia` marks both kinds of group room, and marks them at the front. **Three mints, because there are three ways a Marmot room comes into existence.** `MarmotGroupCreation.create` is the funnel for two of them -- the admin room a group opens after its ceremony, and a subgroup -- and normalising there means neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s convenient room, has a random id rather than a derived one, so it has no key state to adopt and no admin set to bake in and does not pass through that funnel; it applies the rule itself. **Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice from the same bare ceremony-room subject, by two callers that never see each other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room carries. Those two have to be the same string, or the subgroup is not called what its parent certified -- and a certificate is a signature over the name, so a verifier comparing them would see a real mismatch. `of` being idempotent is what makes them agree by construction rather than by both sites being kept in step. **The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup is made, not the subgroup -- and prefixing it too produced two identically-named rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits beside `#Translators` (the group it stood up), which is the distinction the `#` exists to draw. Its subject is trimmed, so the bare name and the two normalised ones cannot differ by whitespace. **The `#` is drawn beside the name field, not pushed into its state.** `name` in `SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the convention, because normalising on every keystroke moves the caret out from under somebody halfway through a word. The coordinator still reads the name they are about to get. Four strings lose the old name -- "Create the #admins group" becomes "Create the admin room", and the three about what "the #admins room" will sign with now say "the admin room". Their keys are renamed with them, since the keys in this catalogue are derived from the text. Around twenty comments, two screen previews and seven test fixtures follow. Docs: the ceremony note states the convention and what it replaces, and the subgroups note's name-field section is rewritten -- it had been arguing from the `"${parent.subject} (#admins)"` synthesis that no longer exists. `docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured debugging log, and rewriting it would falsify a record. Three tests. `MarmotGroupNameTest` pins the rule, idempotence included. `MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both the room row this device draws and the group data every other member reads. `SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed event's tags back out of the signing session: the name the parent is asked to sign is the name `MarmotGroupCreation` will give the room. That last one needed the signable-parent fixture to seed host keys, since a ceremony's signer ids are derived from them rather than stored. **Rooms that already exist keep their names.** The name lives in the epoch-0 group context, so renaming one is an MLS commit every member has to process -- a different change from a naming convention, and not made here. 403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:41:30 +02:00
<string name="create_the_admin_room">Create the admin room</string>
feat: sign a group's key state before its room exists, and put FROST on NIP-17 A room's `GroupKeyState` was the new #admins room's first application message: the coordinator created the room, added the members, and only then asked the group to agree what it signs with. The order is now reversed. The group agrees it while it is still just a ceremony and a NIP-17 chat, and the room is created already knowing. **Two things were wrong with the old order, and neither was cosmetic.** The room's founding fact was settled after the founding, so a session that never reached a quorum left a live room whose every member fell back to rederiving -- which works, but only at the one path the constant names, and says nothing about which ceremony a device should take its share from. And the members who had to sign it were exactly the ones the room had just been created to hold: a member whose key package could not be found was excluded from the room *and* from a decision they held a share of, while `createAdminGroup` refuses to create the room at all in that case. Agreeing first makes the state a precondition of the room rather than an afterthought. **Signing therefore has to work in a NIP-17 room, and `broadcast` is the only place that knows.** In a Marmot room a signing message stays an ordinary inner event, encrypted to the group and addressed to nobody, because who is in the group is the MLS tree's business. In a NIP-17 room it goes out as one sealed gift wrap per member and has to name them all, or the members it left out never hear. Neither shape lets a recipient list decide anything -- the signer set comes from the ceremony's host keys either way -- so tagging somebody does not put them in it and failing to tag somebody only stops them hearing. Everything above `broadcast` is the same protocol; `NostrDao` dispatches the 3032x kinds off the gift-wrap path beside the DKG's, and the outbound path needed no change because `sealGiftWrapPayload` already seals to the room's participants and already refuses MLS rooms. **`signingPath` gains the one case that cannot be self-checked.** Every other candidate is right exactly when walking it reaches the room, which makes the resolution self-checking rather than trusting. A NIP-17 room's id is an aggregation of its members' keys, so no path reaches it and nothing can be checked that way. What the group signs as there is the room it is about to make: the ceremony's key at the app's admin path. That is admitted only when the ceremony is *this room's own* -- `key.chatRoomId == chatRoomId`, read from this device's database -- and the path is the constant rather than anything off the wire, so a proposer still chooses nothing. Naming some other ceremony this device holds a share for gets no path at all, and `completedKey` will not even find a key for a NIP-17 room that did not host one, so such a room cannot open a session; both are tested. **A state's subject is now its own `d` tag, not the room it arrived in.** Those used to be required to agree, and a mismatch was dropped -- the right rule while a state was made in the room it described, and the wrong one now that the two differ by design. Nothing is given up. The check that drop was standing in for is still made and made against the *named* room: `GroupKeyState.verifies` has to rederive it, and `isSignedByGroup` has to find a signature by the key that rederivation reaches. A state can therefore only ever be about a room it derives, whatever room it turned up in, so nobody can point one room at another room's key by putting it through the wrong door. The arrival room survives only as the fallback for a state carrying no `d` tag at all. **`record` holds what it cannot file; `adopt` files it when there is a room.** `GroupKeyState.chatRoomId` is a foreign key, so a state signed before its room exists has nothing to hang on -- which is now the normal case rather than an error. `record` says so and keeps the signed event; `adopt` reads it back off `GroupSignedEvent` and files it the moment a room appears. Both ways into a room end there: the member who creates it, in `createAdminGroup` and before the members are added, since filing is local and doing it while the room is certain to exist beats doing it after a step that can partly fail; and the member who arrives on a Welcome, in `NostrDao`, off the same event they were already holding because it was signed in the room they were already in. Nothing goes on the wire in either case. A member who was not in the ceremony holds no such event and gets nothing, which is right -- they hold no share either, so there is nothing for them to pick the wrong one of. **The screen watches the signed event, not a state row, and that is not interchangeable.** There is no row until there is a room, so the only thing that can say the agreement was reached is the event. `observeSignedGroupKeyState` is a flow over `GroupSignedEvent` by kind for the same reason the button it gates exists. Gating on the session's own items instead was rejected twice over: `complete` writes `stage = COMPLETE` *before* `recordSignedEvents`, so a collector woken by the session row can read before the event lands; and an item can hold a signature that has not been verified yet -- `complete` is where each one is checked against its id and author, and throws if it is not. **The button is one control and two steps, in the order they have to happen.** "Agree the group's signing key" until a quorum has signed, "Create the #admins group" after. Offering both at once would be the old order still available, and `createAdminGroup` refuses it in the view model as well, since the screen not drawing something is not a guard. A failed session re-offers the propose button and nothing else does, because a retry has to be a *new* session: the failed one's nonce seeds have already been published against an aggregate, and reusing one produces two partial signatures under a single secret nonce, which is how a share is extracted. `propose` mints a fresh session id every time, so tapping it is the safe retry by construction. **One bug found in review, which the tests now pin.** `replayStoredMessages` read only `marmotInnerEventDao`, so in a NIP-17 room a message arriving before the proposal it belongs to -- routine on a fresh sync, where a relay hands over a backlog in whatever order it likes -- was stored in the gift-wrap payloads and never read back. It now reads whichever store the room's transport writes to, which has to be the same reading `broadcast` makes. `a nonce arriving before the proposal is replayed out of the gift wraps` fails against the old code. **One wart, taken deliberately.** `GroupSignedEvent.chatRoomId` means the room a signature was made in, which for every event but this one is also the room whose key signed it. The key state is filed under the ceremony's room and authored by the #admins room, so `GroupSignedEvent.verifies` cannot pass on that row -- check it with `GroupKeyStateEvent.isSignedByGroup`, which asks the question the row cannot. Both columns are documented to say so. Re-filing the row under the #admins room once it exists was the alternative and buys nothing: a key state is not chroniclable, so no reader wants it there, and moving a row to keep one helper honest is worse than saying where the helper stops. `ChronicleManager` and `docs/member-chronicle.md` both argued for the `isChroniclable` filter from "every room signs a `GroupKeyStateEvent` as its first act", which is no longer true of any Marmot room. The filter stays and the argument is restated: what it stops is a member replaying any group-signed statement *about* the record as though it were work, and `applyPage` refuses the same kinds coming the other way. The two are a pair and neither is safe to drop on the strength of the other. `ChronicleAssemblyJvmTest` now puts its key state on file by hand, which makes that test sharper rather than hypothetical. `SignedGroupKeyStateTest`'s harness flattens the two transports into one `Queued` shape and each device declares whether its room has MLS state, so every existing test keeps testing the Marmot path and the seven new ones read the same. `GroupKeyStateTest`'s "a state naming another group's key is dropped" splits in two: one holding the room fixed and varying the key, which is still a drop, and one varying both, which is another group's true statement and is now attributed to that group's room rather than refused. 649 jvm tests and 373 common tests pass; `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:15:58 +02:00
<string name="agree_the_groups_signing_key">Agree the group's signing key</string>
feat(marmot): put a # in front of every group's name, and retire (#admins) A device's room list holds two unrelated kinds of room and nothing on a row said which. A NIP-17 room is a conversation between the people in it. A Marmot room is a *group* -- an id its key derives, a membership baked into an MLS tree, admins who can act for it, a signature anyone holding the id can check -- and the two behave differently enough that guessing is a mistake. `"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half. Only the admin room got it; a subgroup got no marker at all, so as soon as a group had one child, half the Marmot rooms on the device were unmarked. It also sorted nowhere near the group it belonged to, and a truncated row drops a trailing suffix first -- so the marker was missing exactly where the list is crowded enough to need it. **The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every member is welcomed with, so a `#` added at display time would be a name this device alone could see. `#Ekklesia` marks both kinds of group room, and marks them at the front. **Three mints, because there are three ways a Marmot room comes into existence.** `MarmotGroupCreation.create` is the funnel for two of them -- the admin room a group opens after its ceremony, and a subgroup -- and normalising there means neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s convenient room, has a random id rather than a derived one, so it has no key state to adopt and no admin set to bake in and does not pass through that funnel; it applies the rule itself. **Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice from the same bare ceremony-room subject, by two callers that never see each other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room carries. Those two have to be the same string, or the subgroup is not called what its parent certified -- and a certificate is a signature over the name, so a verifier comparing them would see a real mismatch. `of` being idempotent is what makes them agree by construction rather than by both sites being kept in step. **The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup is made, not the subgroup -- and prefixing it too produced two identically-named rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits beside `#Translators` (the group it stood up), which is the distinction the `#` exists to draw. Its subject is trimmed, so the bare name and the two normalised ones cannot differ by whitespace. **The `#` is drawn beside the name field, not pushed into its state.** `name` in `SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the convention, because normalising on every keystroke moves the caret out from under somebody halfway through a word. The coordinator still reads the name they are about to get. Four strings lose the old name -- "Create the #admins group" becomes "Create the admin room", and the three about what "the #admins room" will sign with now say "the admin room". Their keys are renamed with them, since the keys in this catalogue are derived from the text. Around twenty comments, two screen previews and seven test fixtures follow. Docs: the ceremony note states the convention and what it replaces, and the subgroups note's name-field section is rewritten -- it had been arguing from the `"${parent.subject} (#admins)"` synthesis that no longer exists. `docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured debugging log, and rewriting it would falsify a record. Three tests. `MarmotGroupNameTest` pins the rule, idempotence included. `MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both the room row this device draws and the group data every other member reads. `SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed event's tags back out of the signing session: the name the parent is asked to sign is the name `MarmotGroupCreation` will give the room. That last one needed the signable-parent fixture to seed host keys, since a ceremony's signer ids are derived from them rather than stored. **Rooms that already exist keep their names.** The name lives in the epoch-0 group context, so renaming one is an MLS commit every member has to process -- a different change from a naming convention, and not made here. 403 common tests, 726 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 11:41:30 +02:00
<string name="the_group_is_agreeing_what_the_admin_room">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.</string>
<string name="the_group_has_agreed_what_the_admin_room">The group has agreed what the admin room will sign with.</string>
<string name="the_group_could_not_agree_what_the_admin">The group could not agree what the admin room will sign with. Ask again — a fresh request is the only safe way to retry.</string>
feat: sign a group's key state before its room exists, and put FROST on NIP-17 A room's `GroupKeyState` was the new #admins room's first application message: the coordinator created the room, added the members, and only then asked the group to agree what it signs with. The order is now reversed. The group agrees it while it is still just a ceremony and a NIP-17 chat, and the room is created already knowing. **Two things were wrong with the old order, and neither was cosmetic.** The room's founding fact was settled after the founding, so a session that never reached a quorum left a live room whose every member fell back to rederiving -- which works, but only at the one path the constant names, and says nothing about which ceremony a device should take its share from. And the members who had to sign it were exactly the ones the room had just been created to hold: a member whose key package could not be found was excluded from the room *and* from a decision they held a share of, while `createAdminGroup` refuses to create the room at all in that case. Agreeing first makes the state a precondition of the room rather than an afterthought. **Signing therefore has to work in a NIP-17 room, and `broadcast` is the only place that knows.** In a Marmot room a signing message stays an ordinary inner event, encrypted to the group and addressed to nobody, because who is in the group is the MLS tree's business. In a NIP-17 room it goes out as one sealed gift wrap per member and has to name them all, or the members it left out never hear. Neither shape lets a recipient list decide anything -- the signer set comes from the ceremony's host keys either way -- so tagging somebody does not put them in it and failing to tag somebody only stops them hearing. Everything above `broadcast` is the same protocol; `NostrDao` dispatches the 3032x kinds off the gift-wrap path beside the DKG's, and the outbound path needed no change because `sealGiftWrapPayload` already seals to the room's participants and already refuses MLS rooms. **`signingPath` gains the one case that cannot be self-checked.** Every other candidate is right exactly when walking it reaches the room, which makes the resolution self-checking rather than trusting. A NIP-17 room's id is an aggregation of its members' keys, so no path reaches it and nothing can be checked that way. What the group signs as there is the room it is about to make: the ceremony's key at the app's admin path. That is admitted only when the ceremony is *this room's own* -- `key.chatRoomId == chatRoomId`, read from this device's database -- and the path is the constant rather than anything off the wire, so a proposer still chooses nothing. Naming some other ceremony this device holds a share for gets no path at all, and `completedKey` will not even find a key for a NIP-17 room that did not host one, so such a room cannot open a session; both are tested. **A state's subject is now its own `d` tag, not the room it arrived in.** Those used to be required to agree, and a mismatch was dropped -- the right rule while a state was made in the room it described, and the wrong one now that the two differ by design. Nothing is given up. The check that drop was standing in for is still made and made against the *named* room: `GroupKeyState.verifies` has to rederive it, and `isSignedByGroup` has to find a signature by the key that rederivation reaches. A state can therefore only ever be about a room it derives, whatever room it turned up in, so nobody can point one room at another room's key by putting it through the wrong door. The arrival room survives only as the fallback for a state carrying no `d` tag at all. **`record` holds what it cannot file; `adopt` files it when there is a room.** `GroupKeyState.chatRoomId` is a foreign key, so a state signed before its room exists has nothing to hang on -- which is now the normal case rather than an error. `record` says so and keeps the signed event; `adopt` reads it back off `GroupSignedEvent` and files it the moment a room appears. Both ways into a room end there: the member who creates it, in `createAdminGroup` and before the members are added, since filing is local and doing it while the room is certain to exist beats doing it after a step that can partly fail; and the member who arrives on a Welcome, in `NostrDao`, off the same event they were already holding because it was signed in the room they were already in. Nothing goes on the wire in either case. A member who was not in the ceremony holds no such event and gets nothing, which is right -- they hold no share either, so there is nothing for them to pick the wrong one of. **The screen watches the signed event, not a state row, and that is not interchangeable.** There is no row until there is a room, so the only thing that can say the agreement was reached is the event. `observeSignedGroupKeyState` is a flow over `GroupSignedEvent` by kind for the same reason the button it gates exists. Gating on the session's own items instead was rejected twice over: `complete` writes `stage = COMPLETE` *before* `recordSignedEvents`, so a collector woken by the session row can read before the event lands; and an item can hold a signature that has not been verified yet -- `complete` is where each one is checked against its id and author, and throws if it is not. **The button is one control and two steps, in the order they have to happen.** "Agree the group's signing key" until a quorum has signed, "Create the #admins group" after. Offering both at once would be the old order still available, and `createAdminGroup` refuses it in the view model as well, since the screen not drawing something is not a guard. A failed session re-offers the propose button and nothing else does, because a retry has to be a *new* session: the failed one's nonce seeds have already been published against an aggregate, and reusing one produces two partial signatures under a single secret nonce, which is how a share is extracted. `propose` mints a fresh session id every time, so tapping it is the safe retry by construction. **One bug found in review, which the tests now pin.** `replayStoredMessages` read only `marmotInnerEventDao`, so in a NIP-17 room a message arriving before the proposal it belongs to -- routine on a fresh sync, where a relay hands over a backlog in whatever order it likes -- was stored in the gift-wrap payloads and never read back. It now reads whichever store the room's transport writes to, which has to be the same reading `broadcast` makes. `a nonce arriving before the proposal is replayed out of the gift wraps` fails against the old code. **One wart, taken deliberately.** `GroupSignedEvent.chatRoomId` means the room a signature was made in, which for every event but this one is also the room whose key signed it. The key state is filed under the ceremony's room and authored by the #admins room, so `GroupSignedEvent.verifies` cannot pass on that row -- check it with `GroupKeyStateEvent.isSignedByGroup`, which asks the question the row cannot. Both columns are documented to say so. Re-filing the row under the #admins room once it exists was the alternative and buys nothing: a key state is not chroniclable, so no reader wants it there, and moving a row to keep one helper honest is worse than saying where the helper stops. `ChronicleManager` and `docs/member-chronicle.md` both argued for the `isChroniclable` filter from "every room signs a `GroupKeyStateEvent` as its first act", which is no longer true of any Marmot room. The filter stays and the argument is restated: what it stops is a member replaying any group-signed statement *about* the record as though it were work, and `applyPage` refuses the same kinds coming the other way. The two are a pair and neither is safe to drop on the strength of the other. `ChronicleAssemblyJvmTest` now puts its key state on file by hand, which makes that test sharper rather than hypothetical. `SignedGroupKeyStateTest`'s harness flattens the two transports into one `Queued` shape and each device declares whether its room has MLS state, so every existing test keeps testing the Marmot path and the seven new ones read the same. `GroupKeyStateTest`'s "a state naming another group's key is dropped" splits in two: one holding the room fixed and varying the key, which is still a drop, and one varying both, which is another group's true statement and is now attributed to that group's room rather than refused. 649 jvm tests and 373 common tests pass; `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:15:58 +02:00
<string name="before_the_room_exists_the_group_signs">Before the room exists, the group signs a statement of which key it will sign with. The room is then created already knowing it.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="creating_new_chat">Creating new chat.</string>
<string name="currently_no_contacts_please_search_and_chat">Currently no contacts. Please search and chat with a few people.</string>
<string name="currently_no_messages_have_been_shared">Currently no messages have been shared.\nBreak the ice.</string>
<string name="delete_group">Delete group</string>
<string name="details">Details</string>
<string name="dialect_name">Dialect name</string>
<string name="dialects">Dialects</string>
<string name="direct_message_detail">Direct message detail</string>
<string name="direct_message_functionality_will_be_here">Direct message functionality will be here.</string>
<string name="direct_message_via_npub">Direct message via npub</string>
<string name="display_recovery_phrase">Display recovery phrase</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="don_t_sign">Don't sign</string>
<string name="download">Download</string>
<string name="download_and_securely_store_everything">Download and securely store everything needed to recover this profile and the coins it holds.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="edit_profile">Edit profile</string>
<string name="eg_chapter_1_the_beginning">eg. Chapter 1 — The Beginning</string>
<string name="eg_first_edition">eg. First Edition</string>
<string name="eg_https_harper_com_2_kill_bird">eg. https://harper.com/2-kill-Bird</string>
<string name="eg_lesotho">eg. Lesotho</string>
<string name="eg_sesotho">eg. Sesotho</string>
<string name="eg_st">eg. st</string>
<string name="eg_to_kill_a_mocking_bird">eg. To Kill a Mocking Bird</string>
<string name="emergency_kit">Emergency kit</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="end_this">end this</string>
<string name="ended">ENDED</string>
<string name="encrypt_and_back_your_recovery_information">Encrypt and back your recovery information up to your Google Drive or iCloud.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="enter_the_name_you_want_to_use_for_your">Enter the name you want to use for your group</string>
<string name="enter_the_name_you_want_to_use_for_your_2">Enter the name you want to use for your profile</string>
<string name="enter_the_translation_for_this_chunk">Enter the translation for this chunk</string>
<string name="events_are_indexed_so_that_we_can_deliver_a">Events are indexed so that we can deliver a premium local first experience.</string>
<string name="everything_else">Everything else</string>
<string name="everything_is_cryptographical_sound_just">Everything is cryptographical sound. Just announcing your profile to the world.</string>
<string name="everything_is_cryptographical_sound_just_2">Everything is cryptographical sound. Just indexing your profile on the device.</string>
<string name="everything_is_cryptographical_sound_just_3">Everything is cryptographical sound. Just need to queue your profile and announce it to the world.</string>
<string name="expired">Expired</string>
<string name="failed">✗ Failed</string>
<string name="follow">follow</string>
<string name="follow_back">follow back</string>
<string name="functionality_coming_soon">functionality coming soon.</string>
<string name="has_not_taken_part_yet">Has not taken part yet</string>
<string name="hide">Hide</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="how_many_admins_have_to_approve_a_change">How many admins have to approve a change?</string>
<string name="how_many_members_will_it_take_to_sign">How many members will it take to sign?</string>
<string name="i_have_saved_my_recovery_phrase_somewhere">I have saved my recovery phrase somewhere safe.</string>
<string name="i_understand_that_if_i_lose_this_phone_and">I understand that if I lose this phone and my recovery phrase, I lose this profile and the funds in its wallet.</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name="if_you_are_new_to_torch_or_just_want_to">If you are new to Mantra or just want to create a fresh profile</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="initial_version_label">Initial version label</string>
<string name="input_npub_or_nip05">Input npub... or nip05</string>
<string name="introduce_yourself">Introduce yourself</string>
<string name="invite">Invite</string>
<string name="invite_a_friend">Invite a friend</string>
<string name="invite_new_member">Invite new member</string>
<string name="it_is_cryptographical_secure_and">It is cryptographical secure, and decentralized, putting you in total control of your digital profile</string>
<string name="just_you_for_now">Just you for now</string>
<string name="keep_the_feed_alive">Keep the feed alive.</string>
<string name="keep_this_phrase_safe_do_not_share_it">Keep this phrase safe.\nDo not share it.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="key">Key</string>
<string name="key_package_management">Key package management</string>
<string name="key_recovery">Key recovery</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="language">Language</string>
<string name="learn_more">Learn more</string>
<string name="leave_group">Leave group</string>
<string name="library">Library</string>
<string name="lightning_invoice">Lightning invoice</string>
<string name="live">LIVE</string>
<string name="live_stream">Live stream</string>
<string name="loading">Loading</string>
<string name="loading_article">Loading article...</string>
<string name="loading_author_information">Loading author information</string>
<string name="loading_author_information_2">Loading author information...</string>
<string name="loading_information">loading information...</string>
<string name="loading_note">Loading note...</string>
<string name="loading_stream">Loading stream...</string>
<string name="loading_preferences">Loading preferences…</string>
<string name="lose_this_phone_before_you_do_and_the">Lose this phone before you do, and the profile goes with it.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="lock_prompt_coming_soon">Lock prompt coming soon.</string>
<string name="malformed_note">Malformed note</string>
<string name="mantra">Mantra</string>
<string name="members">Members</string>
<string name="members_2">members</string>
<string name="name">Name</string>
<string name="name_eg_alan_turing">Name (eg. Alan Turing)</string>
<string name="name_eg_group_discussions">Name (eg. Group Discussions)</string>
<string name="name_of_artifact">Name of artifact</string>
<string name="network_relays">Network relays</string>
<string name="no_wallet_is_open_on_this_device_so_there">No wallet is open on this device, so there is no phrase to show.</string>
<string name="not_backed_up_yet">Not backed up yet</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="new_chat">New chat</string>
<string name="next">Next</string>
<string name="no_artifacts_exists_in_this_groups_library">No artifacts exists in this groups library.</string>
<string name="no_chapters">No chapters.</string>
<string name="no_chapters_yet">No chapters yet.</string>
<string name="no_chat_message_relays_were_found_for_this">No chat message relays were found for this user.</string>
<string name="no_chunks">No chunks.</string>
<string name="no_dialects_have_been_defined_in_this_group">No dialects have been defined in this group yet. Add one from the group's detail screen first.</string>
<string name="no_dialects_have_been_defined_in_this_group_2">No dialects have been defined in this group.</string>
<string name="no_messages_go_to_a_profile_and_send_them_a">No messages. Go to a profile and send them a message.</string>
<string name="no_one_selected_yet">No one selected yet</string>
<string name="no_one_to_add_yet">No one to add yet.</string>
<string name="no_projects_exists_in_this_group">No projects exists in this group.</string>
<string name="no_translations_yet">No translations yet.</string>
<string name="no_versions">No versions.</string>
<string name="not_now">Not now</string>
<string name="nothing_was_created_and_no_key_exists_it_is">Nothing was created and no key exists. It is safe to run it again.</string>
<string name="open_chat">Open chat</string>
<string name="original">Original</string>
<string name="original_text">Original text</string>
<string name="original_text_markdown">Original text (markdown)</string>
<string name="paid">✓ Paid</string>
<string name="paste_the_chapter_s_markdown_blank_lines">Paste the chapter's markdown. Blank lines separate paragraphs into chunks.</string>
<string name="pay">Pay</string>
<string name="pay_now">Pay now</string>
<string name="post">Post</string>
<string name="post_functionality_coming_soon">post functionality coming soon.</string>
<string name="private_to_you">Private to you</string>
feat: give the app a navigation component, and stop the app bar duplicating it Phase 6, step 3. The app had no navigation component of any kind: 43 screens reached by pushing a route, and one home screen whose top app bar carried the only two peer surfaces -- a profile avatar in the leading slot, a search icon in the trailing one. **This is an information-architecture change and was taken as one.** With a single top-level destination, a navigation bar would have held one item and been strictly worse than the app bar it replaced -- M3's caution is to swap only functionally equivalent components. Promoting search and profile to peer destinations is what makes a navigation component mean anything here, and it was put to the product owner rather than inferred. Answered: promote them. The consequence is in `HomeScreen`: the app bar now carries a title and nothing else. Two routes to one destination is the thing the caution is about, and the navigation component is now the one route, at every breakpoint. **Which component, at which breakpoint**, straight from the layout foundation: | compact | navigation bar | | medium, expanded | collapsed rail | | large, extra-large | expanded rail | `NavigationSuiteScaffoldDefaults.navigationSuiteType` is not used, and the difference is the last row -- it stops at the collapsed rail, because it classifies with the three-value window size class rather than the five breakpoints the May 2026 revision published. Deriving from `Breakpoint` reaches the row the library's default cannot, and keeps one source of truth for window width in the app. `NavigationSuiteType.None` on everything else. A navigation bar belongs on the destinations it switches between; on a chat room, a signing screen or an onboarding step -- pushed to and left by coming back -- it is a permanent invitation to lose your place. **Two things the wiring needed.** `ActiveProfileRoute` is addressed by metadata event id, not by public key, and only the home screen ever had one. The nav host now observes it for as long as a key is signed in, and the profile item is *disabled* until it arrives rather than absent -- an item that appears late moves the two beside it, and a bar whose items move under a thumb is worse than one briefly unavailable. The item click pops to `HomeRoute`, not to the graph's start destination. The android docs give the second shape and it would be wrong here: this graph starts at `LoadingRoute`, and onboarding clears the stack with `popUpTo(0)` on its way to home, so by the time these items exist the start destination is not on the stack at all -- popping to it would leave the loading screen underneath as the thing back returns to. **Tests, and one that could not be written.** The breakpoint-to-component table is a pure function so all five rows are asserted; the two rail rows differ only in whether labels are drawn, and nobody opens a 1200dp window on purpose. Four more compose the component around a real nav graph, because `TopLevelDestination.of` matches by `hasRoute` -- reflection over the serialized route -- and a renamed route would fail by never showing the component at all. Navigation in those is driven through the controller rather than by tapping an item. That is a harness limitation, established rather than assumed: a click handler that navigates trips navigation-compose's own main-thread assertion under `runDesktopComposeUiTest`, reproducible in twenty lines containing no app code -- a `NavHost`, two routes and a `TextButton`. What an item's `onClick` builds is asserted where it is a pure function instead. Also `material3-adaptive-navigation-suite`, versioned with material3 rather than with the adaptive library: it is published by the material3 group, and its 1.10.0-alpha05 is what names adaptive 1.2.0 in the first place. Most of the `MantraNavHost` diff is indentation -- the `NavHost` call gained an enclosing composable. `git diff -w` shows the 38 lines that are not. 626 jvm tests green; android compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:53:27 +02:00
<string name="messages">Messages</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="profile">Profile</string>
feat: put the chat list beside the conversation, from the expanded breakpoint up Phase 6, step 4, chat first as the plan asks. On a window 840dp or wider the home screen is now the room list at a fixed width and the selected conversation filling the rest; on anything narrower it is exactly what it was. **Below expanded is not caution, it is the spec.** The breakpoints page says not to put two dense panes in a medium window, and `calculatePaneScaffoldDirective` in `material3-adaptive` says the same thing in code -- `maxHorizontalPartitions = 1` for compact and medium alike. A chat transcript is precisely the dense content that rule is about. It is also what this app can support. `ChatRoomMessagingRoute` is navigated to from **eleven** places -- a DKG ritual finishing, room-type selection, the npub dialog, a profile -- so the conversation has to remain a pushed destination whatever the window is doing. The list pane is a second way to reach it on a wide window, not a replacement for the first. **Why not `ListDetailPaneScaffold`.** The dependency is available and resolves for every target; the scaffold was not used, and the reason is the paragraph above. It earns its API surface -- a navigator, a destination history, an `AnimatedPane` per pane, three experimental opt-ins -- by owning the single-pane case as well: showing the detail *instead of* the list on a phone and animating between them. This app cannot hand it that, so it would sit permanently in its two-pane state and amount to a `Row` with more words and a history nothing reads. What it does have that is worth keeping is its numbers, and `Panes.kt` takes them: 360dp of list at expanded, 412dp from large upward, 24dp between. A hand-built pair measures the same as the scaffold would. **Three smaller decisions.** The floating action button moves into the list pane when there are two. The `Scaffold`'s slot is the bottom-right of the *window*, which with two panes is on top of the transcript's send button; M3 puts a list-detail layout's primary action in the list pane. It is one composable used from both branches so the two cannot drift. `readableContent()` comes off the pair. Capping two panes together to one column's measure is the opposite of what a second pane is for -- each pane holds its own content instead, and the conversation already did. The detail pane says "Pick a conversation to read it here" rather than being an unexplained empty half of a window, and the conversation is keyed on the room so switching rebuilds its view models rather than feeding a new id to ones already subscribed to another room's relays. **Measured in real windows of the widths the phase names.** 400 and 700 are one pane; 1000 splits with a 360dp list; 1400 splits with a 412dp list. 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 reimplementation of two large interfaces. Five more unit tests pin the widths against the directive's, including that the detail pane still clears a 40-character line in the narrowest window that allows two of them. 635 jvm tests green; android compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:01:16 +02:00
<string name="pick_a_conversation_to_read_it_here">Pick a conversation to read it here.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="profile_is_ready">Profile is ready</string>
<string name="profiles">Profiles</string>
<string name="projects">Projects</string>
<string name="proposals">Proposals</string>
<string name="propose">Propose</string>
<string name="propose_artifact">Propose artifact</string>
<string name="propose_chapter">Propose chapter</string>
<string name="propose_dialect">Propose dialect</string>
<string name="propose_translation">Propose translation</string>
<string name="publish_new_key_package">Publish new key package</string>
<string name="re_broadcast">Re-broadcast</string>
<string name="read_to_the_end_of_the_list_to_sign">Read to the end of the list to sign.</string>
<string name="recovery_phrase">Recovery phrase</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="ready_to_sign">Ready to sign</string>
<string name="recents">Recents</string>
<string name="reindex_events">Reindex events</string>
<string name="reposted">reposted</string>
<string name="review">Review</string>
<string name="review_and_confirm">Review and confirm</string>
<string name="review_and_contribute">Review and contribute</string>
<string name="review_and_join">Review and join</string>
<string name="say_what_now">Say what now?</string>
<string name="search">Search</string>
<string name="search_for_people_and_chat_with_them_first">Search for people and chat with them first — everyone you know locally shows up here.</string>
<string name="search_hashtags">Search hashtags</string>
<string name="search_member_functionality">Search member functionality</string>
<string name="search_message_functionality_will_be_here">Search message functionality will be here.</string>
<string name="see_how_deep_the_rabbit_hole_goes">See how deep the rabbit hole goes</string>
<string name="send_message">Send message</string>
<string name="share_profile">Share profile</string>
<string name="shared_key">Shared key</string>
feat: put a room's signing key first on its detail screen, and the signed event behind it A group's `GroupKeyState` had no surface anywhere in the app. It decides which share a member signs with and which identity a reader will see on everything the group signs, and the only way to learn either was to read the logs. The group detail screen now opens with it, and tapping it shows the event a quorum actually put its signature to, with a button to take that event somewhere it can be checked. **First on the screen, above the description.** Which key a room signs as is the fact the rest of the room's signed work stands on -- a dialect, an artifact and a chapter are all worth exactly what the identity behind them is worth -- so it goes before the library and the dialects rather than into the settings-ish tail of the screen with reindexing and leaving. It is absent rather than empty on a room the group has said nothing about: there is no half state to report, since a room either has one a quorum signed or has none, and the shared key entry further down is already where somebody goes to make one. **The row's subtitle is the identity, not the threshold key.** Those are different values -- the group's root ChillDKG key, and that key walked to the room's path -- and only the second one appears on anything. It is what a reader checks a signature against and it is the room's own id, so it is the value a member is most likely to want to compare against something. The whole of it, along with the root key it came from, is one tap away in the sheet. **The state and the event are read separately, and neither is derived from the other.** A `GroupSignedEvent` carries every field the `GroupKeyState` row does, so one read would have done -- but the two mean different things when they are missing. The row is this device's reading, which is what the app resolves a signing request against; the event is the group's statement, with the signature on it, which is the only part that can be checked. A device holding the reading and not the statement should not be shown fields as though they were signed, and the sheet says so instead. It never happens the other way round: a state is only ever written from an event that passed both checks. **`signedEventFor` looks wherever the event is filed, which is not this room.** Since the previous commit a group agrees its key state before the room exists, so the event lives under the NIP-17 room its ceremony ran in and is authored by the Marmot room it is about. Finding it by room would find nothing. It is found by its `d` tag instead, through the same `stateFrom` that lets one be believed at all, so nothing is shown that this device would not have acted on. `stateAmong` and the new `signedEventFor` are now one walk returning both halves, because an event that produces no state must not be shown as though the group had settled anything. **The sheet shows and copies the canonical compact event JSON.** Pretty-printing it would read better in the block and was rejected: the point of copying it is to hand somebody something they can verify, and the moment the display and the copy diverge the button stops being "copy what you are looking at". What is shown is `Event.toJson()`, byte for byte, which is what a nostr tool expects to be given. **The hex is grouped in eights, which the render caught and reading did not.** Captured off a real desktop composition at 360dp, the JSON wrapped fine -- it has quotes and commas to break on -- and every key ran off the side of the sheet with its last characters unreadable. A 64-character key has no space in it, so Compose lays the whole run on one line and lets it overflow. The spaces are the break opportunities. They are also how a value meant to be compared character by character against another member's screen should have been shown in the first place, for the same reason a fingerprint or an account number is grouped. Nothing is copied from those fields, so shaping them for reading costs a paste nothing. **The value colour is stated rather than inherited.** The labels are deliberately quieter at `onSurfaceVariant` and the values are what a member came for, so they name `onSurface` instead of taking whatever `LocalContentColor` happens to be. The JSON block sits on `surfaceVariant`/`onSurfaceVariant`, which `ColorSchemeContrastTest` already measures in all six schemes. **The sheet's body is a composable of its own, and that is what makes it testable.** A `ModalBottomSheet` is a popup in its own window, which a layout test cannot reach into, so `GroupKeyStateSheetContent` is separated from the sheet that contains it. `GroupKeyStateSheetLayoutJvmTest` then renders it at phone width and asserts the *height* of the JSON: a parent that narrow caps the text's layout width whether it wraps or clips, so width would pass either way. The threshold is calibrated against the real measurement rather than guessed -- it renders 224dp wrapped, against roughly 16dp for a single clipped line, so 60dp separates them with room to spare. A second case renders the sheet for a device holding no signed event, since that branch returns early and would otherwise never be laid out. The screen's `@ConformancePreviews` gains a key state, so the row renders under all five conditions rather than only in a group that has held a ceremony. 651 jvm tests and 373 common tests pass; `m3Audit` meets every budget, with the string count unchanged at 39 -- the eleven new pieces of UI text are in the catalogue in sentence case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:52:36 +02:00
<string name="signing_key">Signing key</string>
<string name="the_key_this_room_signs_as">The key this room signs as, and the ceremony it came from.</string>
<string name="what_the_group_signed">What the group signed</string>
<string name="this_room_signs_as">This room signs as</string>
<string name="derivation_path">Derivation path</string>
<string name="key_ceremony">Key ceremony</string>
<string name="agreed_on">Agreed on</string>
<string name="the_signed_event">The signed event</string>
<string name="copy_the_signed_event">Copy the signed event</string>
<string name="copied_the_signed_event">Copied the signed event</string>
<string name="this_device_does_not_hold_the_event">This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="sign">Sign</string>
<string name="sign_in">Sign in</string>
<string name="sign_out">Sign out</string>
<string name="sign_with_the_group_s_key">Sign with the group's key</string>
<string name="signed_their_part">Signed their part</string>
<string name="skip_for_now">Skip for now</string>
<string name="something_went_wrong">Something went wrong</string>
<string name="something_went_wrong_and_we_were_unable_to">Something went wrong and we were unable to sign in to your provided profile. Please try again later.</string>
<string name="something_went_wrong_and_we_were_unable_to_2">Something went wrong and we were unable to sign you up. Please try again later.</string>
<string name="something_went_wrong_and_we_were_unable_to_3">Something went wrong and we were unable to write a new note. Please try again later.</string>
<string name="source_dialect">Source dialect</string>
<string name="start">Start</string>
<string name="start_a_group_chat">Start a group chat</string>
<string name="start_chat">Start chat</string>
<string name="start_chat_via_npub_or_nip05">Start chat via npub or nip05</string>
<string name="start_key_ceremony">Start key ceremony</string>
<string name="startup_error">Startup error</string>
<string name="taking_part_with_these_members">Taking part with these members</string>
<string name="tap_to_load">Tap to load</string>
<string name="tell_friends_to_join_you_so_your_feed_stays">Tell friends to join you so your feed stays lively and fresh.</string>
<string name="the_above_will_be_your_new_note">The above will be your new note.</string>
<string name="the_above_will_be_your_profile">The above will be your profile.</string>
<string name="the_ceremony_was_abandoned">The ceremony was abandoned.</string>
<string name="the_group_can_hold_one_key_together_split_so">The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum.</string>
<string name="the_group_has_a_shared_key">The group has a shared key.</string>
feat(groups): the queue of a list the group curates, read off the relays it named `930d37c8` gave a group the schema: the definition of a list, signed by the room's own key, for strangers to answer. This is the answers. Under every schema card on the group's screen there is now a "View suggestions" button, and it opens the list's *queue* -- every kind 31888 anyone has published in reply to the schema's coordinate, newest first, whoever signed it, with a mark on each one the group has already taken up into the list. It is the second half of the curated-list NIP in this app, the read side of it: suggestions (31888) and canonical entries (31890) are now read and checked here. They are still not written; that is the third half. The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that site's one list: one row per suggestion event rather than one per film, so that a reader can see what is waiting before anything is added and whether the curator has taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the reference for everything the screen decides -- one row per event, the newest version per coordinate, "Curated" where a canonical entry shares the `d`, and a timer standing in for an answer that never comes. **The button is behind no gate, and it is per card.** Every other button in the identity block is hidden from a member holding no share of the key, because each of them proposes a signature by the group. The queue is the one thing in the block the group did not write. A schema exists to be answered by strangers, and reading the answers takes no share of anything, so the button is there for whoever is looking -- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a schema and finds it. It sits under its own card rather than once for the section, in the same `item {}` as the card, because each list has its own queue and a button between two cards would say nothing about which one it opened. **The protocol is ported rule for rule, and the fixtures are the NIP's own.** `nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`, `verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind is the one the schema names; every required field is present; no field without `repeat` appears twice; every value matches its field's type and config; every `require-any` group is met; the `a` root names this schema and no other; and the author is somebody the visibility admits. A canonical entry gets two rules on top -- its author is the schema's author, because curation is the one power a schema does not delegate, and a source pointer, when there is one, is a well-formed suggestion coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest` takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's example, and the curator's sign-off on it, and checks both against the bitcoin.mov schema from the same document; then the doc's own minimum suggestion, which has an IMDb link and no watch link and satisfies `require-any` that way; then every rejection the doc lists and says why it matters -- an unknown type, a year outside 1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a `d` with a space in it -- plus the reply rules, the repeat rule, the closed-list rule and the two canonical rules. **Rejected, not repaired, and read by field.** The NIP says a client MUST verify an entry against its schema and MUST reject one that does not satisfy it rather than showing it with the bad parts blanked out; relays carry malformed, partial and hostile events from other applications. So `suggestion` and `canonical` return the entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema. problems` is an enum rather than a sentence -- so that a form later can show them inline. What comes back is a `CuratedEntry` keyed by the schema's *field names* rather than by tag, because a field is what a form and a screen know an entry by and the tag is only where it was kept: the two `r` tags of the example land on `watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no field of the NIP's schema and are not in the reading -- "tags the schema does not define MAY be present and MUST be ignored". **A pattern this platform cannot compile counts as not matched.** The reference builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring an uncompilable pattern would accept entries the site refuses; refusing them is the conservative reading, and it is documented at the check. **A coordinate splits on the first two colons only.** A `d` may itself contain colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses -- so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`, and the test pins the identifier surviving its own colon and the pubkey being lowercased on the way through. **The queue is a reading over stored rows, with three things to close and one to add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the queue out of it. Closed: an event that does not satisfy the schema; a kind 31890 signed by anybody but the group, which is somebody else's list and marks nothing; and an older version of an entry its author has since replaced, since both kinds are addressable and different relays hold different latest versions, so the merge that realises an edit happens here, newest per `kind:pubkey:d` with the event id breaking ties. Added: `isCurated`, true where a canonical entry the group signed shares the suggestion's identifier -- the coordinate both land on, and the reference page's rule. Newest first, because that is the order a queue is read in. **A stranger is named by their profile, and by their key until one arrives.** The indexer writes a placeholder profile named "LOADING..." the moment a pubkey is first seen, and a queue full of that would be a queue of nobody. `suggesterName` reads the profile only when it is a real one -- `createdAt` past `GENESIS_AT` -- and otherwise the short key, which is at least stable and distinguishing. The row's avatar is tinted from the key like every avatar here, so two suggestions by one stranger read as one stranger. **Read from where the schema says, and the screen says where.** The schema's `relay` tags are signed into the event for exactly this: a client that finds the list anywhere knows where its replies live and cannot be sent elsewhere by an unsigned config. A schema naming none -- the NIP allows it and lets a client pick -- is read from the group's general relay list, the *read* relays of it, since this is a read; and failing an agreed, non-empty one, from this build's own relay, the same fallback the schema editor seeds from. Spellings are normalised so one relay written two ways is one request. The header names the relays asked, because a queue read from the wrong relays and a list nobody has written to look exactly alike from here, and a member should be able to tell them apart. **Nothing here talks to a relay; it is a pull like every other one-off read.** `CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay for the sync pump to drain, carrying the NIP's two queries as one request's two filters -- everything anyone published in reply to the coordinate, and what the *group* published in reply to it, the second scoped by `authors` to spare the relay the work `queueOf` does regardless -- and watches the local table for what comes back. That watch needed a way to observe an arbitrary NIP-01 filter, which the repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes and falls back to text notes for the rest, and none of its shapes is "these kinds with this `a` tag". `observeNostrEventsMatching` applies the filter whole through `NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is anchored and escaped rather than a substring scan; the DAO method behind it names its observed tables explicitly, the events and the profiles joined onto them, so a row whose author's kind 0 arrives after the row does re-emits with the name filled in. What the screen shows is therefore what this device has *stored* for the coordinate -- which is also what it shows with no network at all, and what it showed last time until the relays answer again. `NostrEvent.toEvent` is the small conversion the reading needs to hand a stored row to a verifier written against the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the group's own. **"Still looking" is bounded by time, because nothing else bounds it.** The pump marks a request sent when the REQ goes out and processed when an event comes back. A relay holding nothing for the coordinate answers with an EOSE the pump does not record, so there is no signal for "asked and empty", and a screen that showed the local table's first, empty read would call every list empty for the second before its rows arrived -- which teaches a member not to believe it. So the queue is "being asked" from the request going out until either something lands or ten seconds pass, and only after that is an empty queue empty. The reference store does the same with a six-second timer; ten here because the request queues behind the pump's four subscription slots before it goes out. Rows arriving end it early, a late arrival after it is still shown, and `withQueue` -- the one state merge -- pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers "Ask again", which re-queues the same requests: the one thing the watch cannot do on its own is make more arrive. **The row shows what an entry is, not where to find it.** The screen is driven by a schema it has never seen, so what goes on a row is a decision rather than a layout: the title as the headline, since every list has one; a glance line of the form fields that say what the entry is, as `Label: value`, because a bare "2014 · en" says nothing to somebody who does not know the list's fields; the body where the list has one; and who suggested it, when. Links are left to the sheet -- a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it a member might act on: it says the group already took this up, and re-curating it would revise the entry rather than add one. **The sheet has the whole entry and the event it came as.** Every field the entry answered, labelled as the schema labels it and in the schema's order, so it reads as the form would have; the title not repeated, since it is the heading; tokens and links monospaced because those are compared and copied rather than read. Then the signed event, byte for byte, with a copy button -- the way the key state sheet shows the group's own statement and for the same reason: a prettier rendering is a different string to the one whose id was hashed. It is also the thing a canonical entry is built from, when that half arrives. **Four states, and the transition between them.** Loading while the room and the schema are read; an error, with nothing to retry, for a schema this device does not hold, since the identifier came from a navigation argument and reading it again fails the same way; and a loaded state that is either the rows, or looking, or empty with something to do. `ScreenStateTransition` wraps the `when`, which is the Scaffold's whole content. **The audit's proper-noun list grows by two films.** The preview suggests *The Rise and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and `m3-title-case.py` excludes sample data by name rather than by pattern. **Still nothing has reached a relay, and it bites once more.** Group-signed events are not yet published, so a group's schema has not been where a suggester could find it, and its queue is empty until it has. The screen reads the coordinate `31889:<group>:<d>` all the same and shows whatever a relay holds for it; the suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at the reading. 32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion and canonical entry against the NIP's schema: the reading by field, the minimum suggestion, the source pointer and its absence, curation not being delegated, a malformed source, the wrong kind, the reply rules, a missing required field, the repeat rule both ways, every value rule the doc lists, `require-any`, the closed and private lists, `checkValue` on durations, patterns and https, and the coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows: verified suggestions newest first with the broken, the foreign and the wrong-kind ones dropped, an edit collapsing to its newest version, the curated mark placed by the group's canonical entry and not a stranger's, and the naming of a suggester with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest` (6) covers the relay order and the read-only filter, one request per relay carrying both queries and the watch covering both kinds, the three outcomes of `withQueue`, an `initiate` run end to end against fakes -- the relays asked, the filter watched, the rows shown, the looking ended -- and a missing schema being an error rather than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header, the row's glance line with the link kept off it, the mark on the right row and only that row, looking against empty, and the sheet with its fields, its link and its event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the button under each card in turn and in front of a member with no share. 1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@1e52fc8f25e29017f1acb72697166ecdfe99f320
2026-09-12 12:54:34 +02:00
<string name="the_recovery_phrase_sometimes_called_a_seed">The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good.</string>
<string name="these_are_your_keys_keep_them_safe_so_they">These are your keys. Keep them safe so they can keep unlocking this profile and its coins, even when you lose or change your phone.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="this_artifact_has_no_version_for_a">This artifact has no version for a translation to be of.</string>
<string name="this_artifact_has_no_version_for_a_chapter">This artifact has no version for a chapter to attach to.</string>
<string name="this_chapter_has_no_chunks">This chapter has no chunks.</string>
<string name="this_device_holds_no_phrase_for_the_profile">This device holds no phrase for the profile that is signed in.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="this_decides_who_can_change_the_group_later">This decides who can change the group later. You can't switch afterwards.</string>
<string name="this_group_has_no_shared_key_so_it_cannot">This group has no shared key, so it cannot sign them in.</string>
<string name="this_group_has_not_been_asked_to_sign">This group has not been asked to sign anything yet.</string>
<string name="this_is_fixed_once_the_ceremony_runs">This is fixed once the ceremony runs. Changing it later means generating a new key.</string>
<string name="this_is_the_last_thing_the_ceremony_needs">This is the last thing the ceremony needs from you.</string>
<string name="this_will_be_shown_when_people_open_the_chat">This will be shown when people open the chat for more details.</string>
<string name="this_will_be_shown_when_people_open_your">This will be shown when people open your profile.</string>
<string name="this_will_be_the_display_name_for_this_chat">This will be the display name for this chat room.</string>
<string name="this_will_be_the_display_name_for_your">This will be the display name for your profile and also important for search.</string>
feat(identity): list, start and read as an identity that holds no key Phase 3 of docs/npub-sign-in.md. listIdentities runs the migration from nostr-keys.dat before its read -- there rather than inside the reader, so a listing that writes is a named step and not a surprise, and from init, before anything else touches either file. An old file that cannot be read is left where it is and reported the way an unreadable credentials file is, so the user sees an error rather than silently losing every imported identity. The selector says "read only" under the npub of a public-key identity, before the tap: two identities can share an avatar and a name, and only one of them will let the user send a message. Identity.toKeyPair is now the one place a quartz KeyPair is built from an identity. With a secret it is the pair as before, the public key a cross-check; without one it is quartz's read-only constructor. What it must never be is KeyPair(privKey = null) with nothing else -- that is quartz's "make me a new key" -- and decryptGiftWrapSeal builds exactly that from a forwarded pair, which is why the helper has the trap on it. The synchronization view model runs the two pumps that read for every identity and the two that write only for one that can sign. Stated as a rule because Phase 5 leans on it: a read-only identity never sends anything to a relay, not a signature and not a copy. The broadcast pump would find nothing, and the live subscriptions are the gift-wrap inbox and the group-membership follow, both fetching what this identity cannot open. One guard in NostrDao.indexNostrEvent, after the isAddressedTo check: a wrap addressed to a key the pair does not hold is kept, event and wrap, the way someone else's mail already is. storeNostrEvent is @Transaction and indexes inside it, so before this the unseal ran with a key nobody has, threw, and the throw took the event with it while logging as a decryption failure. Verified both ways: the new DAO test fails on exactly that case with the guard removed and passes with it, and the same wrap opens once the pair holds its key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@7db863e3925641bc731816f72bfed90f020566c6
2026-09-12 20:28:57 +02:00
<string name="read_only">Read only</string>
feat(ui): what a read-only identity is shown, and what it is not Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a product. One question, asked in one way. LocalCanSign is a composition local for the snackbar host's reason: screens do not receive the identity, MetadataEventDetail -- where follow and send message live -- is several composables below anything that could be handed more, and a parameter threaded through twenty-six lists is forgotten in the twenty-seventh. ProvideSigningCapability sits once above the navigation suite. The default is true rather than an error, the one way this differs from the snackbar host: the provider cannot be forgotten per screen, so the only things composed outside it are previews and tests. And it is a capability, not a kind -- "can this identity sign?", not "is this an npub?" -- so that a remote signer is not a fourth value in every when. The inventory, hidden rather than disabled because the empty state beside each says why: HomeScreen's New chat in both layouts, its sheet and its npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit profile and send message (the "follows you" state still shows -- a fact, not an action); ActiveProfileScreen's key package management and key recovery; ShareProfileScreen's re-broadcast, which signs nothing but queues a copy for the finder relays, and a read-only identity puts nothing on a relay; SocialPreconditionScreen's invite and view invites, pending today and writes when they exist; and the not-found screen's set-up form, since a kind 0 has to be signed. Everything else that writes is behind a chat room, which a read-only identity can never open. The Messages tab, for a read-only identity, is an EmptyState where the rooms would be -- one column at every width, since a two-pane layout is a list beside a detail and there is no list -- whose message says which absence it is and whose action is the upgrade: Sign in with the nsec, which opens the same screen as landing's, hits the credentials file's upgrade rule, and comes back through startup with rooms in it. ChatRoomListViewModel is not composed, so the inbox sync and the MLS negentropy it would queue are not queued. Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens under each value of LocalCanSign and looks for the controls by text, on the unmerged tree so that "does not exist" is not vacuous. The M3 audit's budgets hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@e31033e857878c6d62830c5fd34918dfb26abfe5
2026-09-12 20:38:18 +02:00
<string name="messages_need_the_secret_key_this_profile">Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent.</string>
<string name="sign_in_with_the_nsec">Sign in with the nsec</string>
feat(identity): two exits for an identity that holds no key Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors. ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out -- the credential out of the file, the metadata hidden, the account rows gone, in that order so a failure partway leaves the credential on disk rather than an identity the selector lists but nothing can open. Since the credentials file the first step is the same call for both credential kinds, so it is one function; a mnemonic identity is refused at the first step, because removing a seed is a wallet question this does not answer. The nsec view model calls it now and keeps only its own state. Sign out on the profile tab does, for a read-only identity only, what its colour has been promising: a confirmation naming the npub -- this device holds no key for it, so there is nothing to lose; the profile stays on the relays -- then the identity leaves the device and the nav host's tail re-lists and clears the active identity, which shows the selector or, if this was the last one, Landing. It is the first real sign out in the app. For the other two kinds the button keeps its pending route. SignOutViewModel owns the confirmation and the in-flight state; SignOutDependencies bundles what the screen needs so that a caller with none of it -- previews, tests -- passes nothing. The not-found screen had, for a read-only identity, no exit: Phase 5 hid the set-up form (a kind 0 has to be signed), the profile tab is not reachable before ProfileLoaded, and a user whose npub was found on no relay could try again for ever. So a third action, shown only where the second is not: use a different key, which is the same sequence behind the same dialog, reached from the other end of the identity's life. Tests: the sequence and where it stops, the view model's states around it, and the two screens composed as each kind -- the profile's sign out asking first and naming the npub, the not-found screen offering the form to one kind and the other key to the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@a586b7c116529f01a4414e40b0eb41f5dff419cc
2026-09-12 20:45:34 +02:00
<string name="sign_out_of_this_profile_question">Sign out of this profile?</string>
<string name="use_a_different_key_question">Use a different key?</string>
<string name="this_device_holds_no_key_for_s_so_there">This device holds no key for %1$s, so there is nothing to lose. The profile stays on the relays, and you can sign in again any time.</string>
<string name="could_not_sign_out_please_try_again">Could not sign out. Please try again.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="this_will_give_you_write_access_to_the">This will give you write access to the profile.</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name="torch_will_be_broadcast_what_you_publish_to">Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="translate_chunk">Translate chunk</string>
<string name="translate_into_which_dialect">Translate into which dialect?</string>
<string name="translated_text">Translated text</string>
<string name="translation">Translation</string>
<string name="translation_detail">Translation detail</string>
<string name="translations">Translations</string>
<string name="transmit_note">Transmit note</string>
<string name="trending_notes_functionality_coming_soon_in">Trending notes functionality coming soon. In the meantime search for what you are looking for.</string>
<string name="try_again">Try again</string>
<string name="type_out_what_you_would_like_to_publish">Type out what you would like to publish</string>
<string name="unfollow">Unfollow</string>
<string name="unlocking_your_phrase">Unlocking your phrase…</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="unsupported_event_kind">Unsupported event kind</string>
<string name="untitled_article">Untitled article</string>
<string name="url">Url</string>
<string name="version">Version</string>
<string name="versions">Versions</string>
<string name="view_and_accept_invites_you_may_have">View and accept invites you may have received to stay connected with others.</string>
<string name="view_invites">View invites</string>
<string name="waiting_for_you">Waiting for you</string>
<string name="waiting_for_your_signature">Waiting for your signature</string>
<string name="we_are_looking_for_your_profile_on_as_many">We are looking for your profile on as many relays as possible. Nostr aims to be decentralized by distributing data to multiple nodse.</string>
<string name="we_are_searching_the_internet_to_find_your">We are searching the internet to find your profile and complete sign in.</string>
<string name="we_couldn_t_find_the_local_profile_please">We couldn't find the local profile. Please try again later.</string>
<string name="we_couldn_t_find_your_nostr_event_please_try">We couldn't find your nostr event. Please try again later.</string>
<string name="what_s_your_comment_on_the_below">What's your comment on the below</string>
<string name="what_s_your_reply_to_the_above">What's your reply to the above</string>
<string name="what_should_people_know_about_you">What should people know about you?</string>
<string name="what_vibrations_do_you_want_to_send_out">What vibrations do you want to send out?</string>
<string name="what_will_be_discussed_in_this_chat_room">What will be discussed in this chat room.</string>
<string name="write_down_and_secure_the_12_word_phrase">Write down and secure the 12 word phrase that this profile and its wallet are derived from.</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="who_will_you_be_passing_the_aux_to">Who will you be passing the aux to?</string>
<string name="you">You</string>
<string name="you_and_1_other">You and 1 other</string>
<string name="you_are_about_to_create_a_nostr_profile">You are about to create a NOSTR profile.</string>
<string name="you_can_still_carry_on_and_invite_people">You can still carry on and invite people later.</string>
<string name="you_have_not_backed_up_your_recovery_phrase">You have not backed up your recovery phrase</string>
<string name="you_only_live_once_lose_this_phone_and_the">You only live once. Lose this phone and the profile goes with it, along with anything it holds.</string>
<string name="you_said_you_wrote_it_down">You said you wrote it down</string>
<string name="yolo">YOLO</string>
refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315 call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals in composables go 332 -> 76; `stringResource` goes 0 -> 374. **The extractor took four attempts, and each failure is why it is checked in.** *A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a composable, and rewriting it failed with "@Composable invocations can only happen from the context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and only literals genuinely inside one are touched. *A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file pairs one string's closing quote with the next string's opening quote, so "literals" came out as several lines of Kotlin. Restricting the body to one line fixed that and left a subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals belonging to an outer template, and left-to-right matching lifts them out as strings of their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over nested literals, and stop at the closing quote at depth zero. *A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b" is not something a translator can work with -- word order differs between languages. Three filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass -- a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is: (if (proposal.eventCount == 2) "event" else "events") + Testing the line rather than the literal catches those four sites while leaving a genuine either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+` and both branches are whole strings. **Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)` returned Don\'t sign backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them would have rendered with a visible backslash, on screens nobody opens often. What makes this worth a permanent test rather than a fixed script: escape handling is **partial**, not absent. The same run showed `\n` *is* processed -- "Currently no messages have been shared.\nBreak the ice." comes back with a real newline. So there is no family rule to rely on, and the next escape somebody adds needs checking on its own. `StringCatalogueJvmTest` asserts all three cases through `getString`, which is the non-composable reader for the same resources and needs no composition. It found the bug before a device did. **Names are derived from content**, snake_cased and truncated at a word boundary: `something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an automated extraction, with a known cost -- rewording the copy leaves the name slightly stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a name asserting the wrong purpose is worse than one that is a little dated. **1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix wallet fork's entire string table with nothing referencing it; it now holds this app's own, plus `app_name`. **What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and an argument order decided per site, and 30 concatenation fragments, which need their sentences reassembled first. Both are the next commit, and both are jobs where a script should not guess. **Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 -- three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs on emulator-5554 with its text reading correctly through onboarding and the message list. `m3-audit.sh --check` exits 0. `ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun; it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches chronicle code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
<string name="you_will_be_in_full_control_of_this_profile">You will be in full control of this profile. If you would like to use it for the long term please remember to backup the profile/keys.</string>
<string name="your_profile_is_almost_ready_just_getting_it">Your profile is almost ready... just getting it's first cryptographic signature together.</string>
<string name="your_share_of_it_is_on_this_device_only_your">Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.</string>
refactor: make the 40 interpolated UI strings format strings, and assert the argument order Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to ${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call passing the expression. 49 call sites. Literals in composables go 76 -> 39; `stringResource` goes 374 -> 424. **A silent bug in the previous commit's extractor, found by this one.** Imports were tested with `statement in source`, and the generated accessors are named after their strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of `...resources.translate_into_which_dialect`. The substring test decided the import was already there, and the compiler reported "Unresolved reference 'translate'" in a file whose imports looked complete. Both extractors now match whole lines, and the helper carries the explanation. **Four filters, each earned by something the dry run got wrong.** *A template that is only interpolation has nothing to translate.* `Text("$name")` would have become a resource holding `%1$s` -- longer, slower, and no more localisable than the code it replaced. *A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a separator. The test has to be on the format string rather than on the literal halves: a template opening with an interpolation leaves the first part empty and the second starting with the separating space, which makes "%1$s Key packages" look like a fragment when it is a whole label. *`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the six visible characters of the escape. They are decoded into the resource, which is UTF-8 and can hold `·` directly. `\\n` is **not** decoded, because StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an XML value would be reflowed by the parser. *A term of a `+` concatenation is still not a string.* Same rule as the plain extractor. **Three copy problems surfaced only here, because interpolated strings had never been checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had `"replying To ${…}"`. And a third instance of the old product name, in `"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather than a one-off: title case inside a template is still unchecked, and there are 83 concatenation fragments left where it could hide. **Two new assertions, on the two things a compiler cannot see.** Argument *order* is decided by where each `${…}` sat, and a transposition compiles and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument string are asserted end to end. The three-argument one doubles as the check that `·` was decoded rather than passed through. **What is deliberately left.** 83 literals that are terms of a `+` concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what the whole sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` -- which want a real plural resource rather than a format argument, and that is an API choice rather than a rewrite. `m3-extract-formatted.py --remaining` lists them. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349. `:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on emulator-5554 through onboarding, the message list and a chat room with its text intact. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:45:35 +02:00
<string name="add_chapter_to">Add chapter to %1$s</string>
<string name="add_people_to">Add people to %1$s</string>
<string name="chapter">Chapter %1$s</string>
<string name="chapter_words_characters">Chapter %1$s · %2$s words · %3$s characters</string>
<string name="chunk">Chunk %1$s</string>
<string name="chunks">Chunks (%1$s)</string>
<string name="chunks_translated">%1$s/%2$s chunks translated</string>
<string name="event_s_still_unreadable">%1$s event(s) still unreadable%2$s</string>
<string name="events_signed_together">%1$s events, signed together</string>
<string name="everyone_has_to_be_online_at_the_same_time">Everyone has to be online at the same time — the ceremony can only finish once all %1$s of you have taken part.</string>
<string name="functionality_coming_soon_2">"%1$s" functionality coming soon</string>
<string name="how_should_be_run">How should %1$s be run?</string>
<string name="invite_2">Invite %1$s</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name="key_packages">%1$s key packages</string>
refactor: make the 40 interpolated UI strings format strings, and assert the argument order Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to ${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call passing the expression. 49 call sites. Literals in composables go 76 -> 39; `stringResource` goes 374 -> 424. **A silent bug in the previous commit's extractor, found by this one.** Imports were tested with `statement in source`, and the generated accessors are named after their strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of `...resources.translate_into_which_dialect`. The substring test decided the import was already there, and the compiler reported "Unresolved reference 'translate'" in a file whose imports looked complete. Both extractors now match whole lines, and the helper carries the explanation. **Four filters, each earned by something the dry run got wrong.** *A template that is only interpolation has nothing to translate.* `Text("$name")` would have become a resource holding `%1$s` -- longer, slower, and no more localisable than the code it replaced. *A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a separator. The test has to be on the format string rather than on the literal halves: a template opening with an interpolation leaves the first part empty and the second starting with the separating space, which makes "%1$s Key packages" look like a fragment when it is a whole label. *`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the six visible characters of the escape. They are decoded into the resource, which is UTF-8 and can hold `·` directly. `\\n` is **not** decoded, because StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an XML value would be reflowed by the parser. *A term of a `+` concatenation is still not a string.* Same rule as the plain extractor. **Three copy problems surfaced only here, because interpolated strings had never been checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had `"replying To ${…}"`. And a third instance of the old product name, in `"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather than a one-off: title case inside a template is still unchecked, and there are 83 concatenation fragments left where it could hide. **Two new assertions, on the two things a compiler cannot see.** Argument *order* is decided by where each `${…}` sat, and a transposition compiles and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument string are asserted end to end. The three-argument one doubles as the check that `·` was decoded rather than passed through. **What is deliberately left.** 83 literals that are terms of a `+` concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what the whole sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` -- which want a real plural resource rather than a format argument, and that is an API choice rather than a rewrite. `m3-extract-formatted.py --remaining` lists them. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349. `:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on emulator-5554 through onboarding, the message list and a chat room with its text intact. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:45:35 +02:00
<string name="next_with">Next with %1$s</string>
<string name="nostr">nostr:%1$s..</string>
<string name="nostr_2">nostr:%1$s...</string>
<string name="of">%1$s of %2$s</string>
<string name="of_members_will_be_needed_to_sign_with_this">%1$s of %2$s members will be needed to sign with this key.</string>
<string name="of_them_could_not_be_read">%1$s of them could not be read</string>
<string name="once_invited_will_be_able_to_receive_and">Once invited will be able to receive and send private message sent to all the %1$s members in the chat room.</string>
<string name="private_message_to">Private message to %1$s</string>
<string name="private_to">Private to %1$s</string>
<string name="proposals_are_waiting_for_your_signature">%1$s proposals are waiting for your signature</string>
<string name="recovered_of_event_s">Recovered %1$s of %2$s event(s)%3$s</string>
<string name="recovered_of_still_unreadable">Recovered %1$s of %2$s · %3$s still unreadable%4$s</string>
<string name="reply_privately_to">Reply privately to %1$s</string>
<string name="reply_to">Reply to %1$s</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name="replying_to">Replying to %1$s</string>
refactor: make the 40 interpolated UI strings format strings, and assert the argument order Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to ${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call passing the expression. 49 call sites. Literals in composables go 76 -> 39; `stringResource` goes 374 -> 424. **A silent bug in the previous commit's extractor, found by this one.** Imports were tested with `statement in source`, and the generated accessors are named after their strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of `...resources.translate_into_which_dialect`. The substring test decided the import was already there, and the compiler reported "Unresolved reference 'translate'" in a file whose imports looked complete. Both extractors now match whole lines, and the helper carries the explanation. **Four filters, each earned by something the dry run got wrong.** *A template that is only interpolation has nothing to translate.* `Text("$name")` would have become a resource holding `%1$s` -- longer, slower, and no more localisable than the code it replaced. *A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a separator. The test has to be on the format string rather than on the literal halves: a template opening with an interpolation leaves the first part empty and the second starting with the separating space, which makes "%1$s Key packages" look like a fragment when it is a whole label. *`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the six visible characters of the escape. They are decoded into the resource, which is UTF-8 and can hold `·` directly. `\\n` is **not** decoded, because StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an XML value would be reflowed by the parser. *A term of a `+` concatenation is still not a string.* Same rule as the plain extractor. **Three copy problems surfaced only here, because interpolated strings had never been checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had `"replying To ${…}"`. And a third instance of the old product name, in `"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather than a one-off: title case inside a template is still unchecked, and there are 83 concatenation fragments left where it could hide. **Two new assertions, on the two things a compiler cannot see.** Argument *order* is decided by where each `${…}` sat, and a transposition compiles and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument string are asserted end to end. The three-argument one doubles as the check that `·` was decoded rather than passed through. **What is deliberately left.** 83 literals that are terms of a `+` concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what the whole sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` -- which want a real plural resource rather than a format argument, and that is an API choice rather than a rewrite. `m3-extract-formatted.py --remaining` lists them. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349. `:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on emulator-5554 through onboarding, the message list and a chat room with its text intact. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:45:35 +02:00
<string name="searching_for_on">Searching for %1$s on "%2$s"</string>
<string name="selected">%1$s selected</string>
<string name="sent_a_private_message_to">%1$s sent a private message to %2$s</string>
<string name="shared_key_for">Shared key for %1$s</string>
<string name="to_join_the_chat_room">to join the %1$s chat room.</string>
<string name="translate">Translate %1$s</string>
<string name="unsupported_event_kind_2">Unsupported event kind: %1$s</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name="was_created_but_couldn_t_be_added_yet_invite">%1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Mantra.</string>
refactor: make the 40 interpolated UI strings format strings, and assert the argument order Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to ${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call passing the expression. 49 call sites. Literals in composables go 76 -> 39; `stringResource` goes 374 -> 424. **A silent bug in the previous commit's extractor, found by this one.** Imports were tested with `statement in source`, and the generated accessors are named after their strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of `...resources.translate_into_which_dialect`. The substring test decided the import was already there, and the compiler reported "Unresolved reference 'translate'" in a file whose imports looked complete. Both extractors now match whole lines, and the helper carries the explanation. **Four filters, each earned by something the dry run got wrong.** *A template that is only interpolation has nothing to translate.* `Text("$name")` would have become a resource holding `%1$s` -- longer, slower, and no more localisable than the code it replaced. *A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a separator. The test has to be on the format string rather than on the literal halves: a template opening with an interpolation leaves the first part empty and the second starting with the separating space, which makes "%1$s Key packages" look like a fragment when it is a whole label. *`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the six visible characters of the escape. They are decoded into the resource, which is UTF-8 and can hold `·` directly. `\\n` is **not** decoded, because StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an XML value would be reflowed by the parser. *A term of a `+` concatenation is still not a string.* Same rule as the plain extractor. **Three copy problems surfaced only here, because interpolated strings had never been checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had `"replying To ${…}"`. And a third instance of the old product name, in `"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather than a one-off: title case inside a template is still unchecked, and there are 83 concatenation fragments left where it could hide. **Two new assertions, on the two things a compiler cannot see.** Argument *order* is decided by where each `${…}` sat, and a transposition compiles and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument string are asserted end to end. The three-argument one doubles as the check that `·` was decoded rather than passed through. **What is deliberately left.** 83 literals that are terms of a `+` concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what the whole sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` -- which want a real plural resource rather than a format argument, and that is an API choice rather than a rewrite. `m3-extract-formatted.py --remaining` lists them. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349. `:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on emulator-5554 through onboarding, the message list and a chat room with its text intact. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:45:35 +02:00
<string name="was_created_but_its_shared_key_ceremony">%1$s was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.</string>
<string name="words_characters">%1$s words · %2$s characters</string>
<string name="word_position">#%1$s</string>
refactor: make the 40 interpolated UI strings format strings, and assert the argument order Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to ${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call passing the expression. 49 call sites. Literals in composables go 76 -> 39; `stringResource` goes 374 -> 424. **A silent bug in the previous commit's extractor, found by this one.** Imports were tested with `statement in source`, and the generated accessors are named after their strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of `...resources.translate_into_which_dialect`. The substring test decided the import was already there, and the compiler reported "Unresolved reference 'translate'" in a file whose imports looked complete. Both extractors now match whole lines, and the helper carries the explanation. **Four filters, each earned by something the dry run got wrong.** *A template that is only interpolation has nothing to translate.* `Text("$name")` would have become a resource holding `%1$s` -- longer, slower, and no more localisable than the code it replaced. *A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a separator. The test has to be on the format string rather than on the literal halves: a template opening with an interpolation leaves the first part empty and the second starting with the separating space, which makes "%1$s Key packages" look like a fragment when it is a whole label. *`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the six visible characters of the escape. They are decoded into the resource, which is UTF-8 and can hold `·` directly. `\\n` is **not** decoded, because StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an XML value would be reflowed by the parser. *A term of a `+` concatenation is still not a string.* Same rule as the plain extractor. **Three copy problems surfaced only here, because interpolated strings had never been checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had `"replying To ${…}"`. And a third instance of the old product name, in `"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather than a one-off: title case inside a template is still unchecked, and there are 83 concatenation fragments left where it could hide. **Two new assertions, on the two things a compiler cannot see.** Argument *order* is decided by where each `${…}` sat, and a transposition compiles and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument string are asserted end to end. The three-argument one doubles as the check that `·` was decoded rather than passed through. **What is deliberately left.** 83 literals that are terms of a `+` concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what the whole sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` -- which want a real plural resource rather than a format argument, and that is an API choice rather than a rewrite. `m3-extract-formatted.py --remaining` lists them. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349. `:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on emulator-5554 through onboarding, the message list and a chat room with its text intact. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:45:35 +02:00
<string name="you_and_others">You and %1$s others</string>
feat: give the app somewhere to report an outcome, and every dead-end error a way out Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural. **Sixteen copies of the same dead end.** The tree held sixteen instances of Column(horizontalAlignment = CenterHorizontally) { Spacer(Modifier.height(48.dp)) Text("Something went wrong") } and five of the same shape saying "No events were found". **Not one of the sixteen offered a retry.** Every failure in this app named no cause and had no way forward but the back button. `ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable so that passing null is a *decision* a reader can see, rather than the absence of a parameter nobody thought about. `EmptyState`'s message is **required**, with no default, and that is the point of the change rather than a detail. "No events were found" was shown for five different absences: nobody you follow, nobody following you, an empty feed, no replies, no search results. A shared default would have preserved exactly that. They now read "You aren't following anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted. **Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no `SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package published, a message not sent -- had nowhere to be reported, so the code either said nothing or navigated away and hoped. `LocalSnackbarHostState` is a composition local rather than a parameter because of where the reporting happens: a view model coroutine finishing a call is several composables below the `Scaffold` that owns the host, and threading the state down would be the same plumbing repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one Scaffold is composed at a time under a NavHost, so the message renders on whichever screen is on top. It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely the failure this file exists to end. **Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and `rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and the count goes 2 -> 3. **Externalising the strings made four copy problems visible, which is the argument for having done it.** With 364 strings in one file rather than scattered through 60 composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the old product name** were sitting in plain sight. All corrected. (They had been fixed once already and lost: the previous commit reverted the tree to fix an unrelated import bug and re-ran the extractor over the original text. Worth recording, because it is what a revert-and-redo costs when a script is the thing being iterated on.) **And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned `.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source scan can see; the check has to follow. **Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state composables and the snackbar host are composition-time behaviour and this repo has no Compose UI test infrastructure; what stands in for it is the device run above. `m3-audit.sh --check` exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
<string name="not_following_anyone_yet">You aren't following anyone yet.</string>
<string name="nobody_following_you_yet">Nobody is following you yet.</string>
<string name="nothing_in_this_feed_yet">Nothing in this feed yet.</string>
<string name="no_replies_to_this_yet">No replies to this yet.</string>
<string name="nothing_matched_that_search">Nothing matched that search.</string>
<string name="key_package_published">Key package published</string>
<string name="key_package_rotated">Key package rotated</string>
feat(subgroups): the four-rung ladder, the picker, and the list a parent reads off its own signatures Phase 7 of docs/subgroups.md, and the first commit where a user can make a subgroup. Four pieces. **A subgroups section on the group detail screen**, above members, listing `SubgroupManager.subgroupsOf` -- so it shows a child this device holds no room for, which is the normal position of a member who is not in the subgroup and of everybody between the certificate being signed and the room being created. A row titles itself from the *room* where there is one and only otherwise from the certificate, because the certificate's name and `p` tags are the founding roster and a renamed or grown subgroup would otherwise be listed under a name nobody uses. The supporting line says which of the two absences it is: certified but not created, or created and you are not in it. Tapping opens the child where this device has it and the certificate where it does not, since that is the whole of what is known and it is checkable. **A parent row on the child's detail screen**, directly under the signing key, because between them they are what the room *is*: the identity it signs as and whose child it is. It comes off the verified `GroupKeyState.parentChatRoomId`, so a member welcomed in after the founding sees nothing there rather than an unverified guess. **`SelectSubgroupAdminsScreen`**, where the three things that cannot change later are settled. The pool is the parent's own members, admins and non-admins alike and marked rather than filtered -- the point of a subgroup is that it can be run by people the parent does not let run the parent. The coordinator is shown, ticked and locked, since they hold a share by construction and leaving them off the list would make "pick two more" read as a group of two. Key packages are resolved as the screen opens and a member without one is marked and unselectable. A key package is one-time-use, so every group a member joins burns one; `MarmotGroupCreation` would refuse to create the room for a missing one -- correctly, the address being permanent -- but only after a ChillDKG, a parent quorum and a child quorum had all completed, each needing every selected admin present. Finding out at the picker costs nothing and finding out at step 4 costs three ceremonies. The supporting line names the remedy rather than the diagnosis, because only its owner can publish another. The quorum stepper is here and nowhere else, and that is a protocol fact: ChillDKG hashes the threshold and the host keys into the session identity, so `t` is fixed the moment the proposal goes out. It is also the one value picked for other people, and consent survives it -- `t` rides on the proposal, `acceptProposal` re-checks it against `quorumRange`, and the host-key gate is where each invitee agrees to the `t`-of-`n` they can now see. **The ritual screen grows a rung rather than being cloned.** `DkgRitualRoute` takes an optional `parentChatRoomId`, and with one the ladder is four steps instead of three: key, certificate, key state, room. A parallel subgroup screen would have duplicated a progress ladder, a threshold picker, three approval gates and a key-state rung in order to insert one step, and the copies would drift within a release. The certificate rung is watched off the *parent's* signed events and sessions rather than this room's -- it is signed where the parent's key can sign it, which is never the ceremony's room. The key-state button stays shut until it is done, because a subgroup's state carries the certificate and `GroupKeyStateManager` refuses one without it; opening that session early would throw rather than fail. And `createAdminGroup` passes the verified parent through to the room, names a subgroup what the coordinator called it rather than "X (#admins)", and says so. No new approval UI. The certificate is a `FrostSigningEvents.PROPOSAL` in the parent's room and the key state one in the ceremony room; `ProposalListScreen` and `FrostSigningScreen` already show and approve both, on both transports. Six repository methods carry it: `subgroupsOf`, `parentOf`, `canSign` and `refuseSubgroup` on `ChatRepository`, and `proposeBirthCertificate`, `observeBirthCertificate` and `proposeSubgroupKeyState` on `DkgRepository`. The view models talk to repositories and the managers take the database, which is where the rest of the app has that line. `refuseSubgroup` returns a refusal when it cannot compute one, because a guard that fails open is not a guard. 25 new strings in the catalogue in sentence case; 397 common tests, 701 jvm tests, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:47:29 +02:00
<string name="subgroups">Subgroups</string>
<string name="add_subgroup">Add subgroup</string>
<string name="no_subgroups_have_been_made_by_this_group">No subgroups have been made by this group.</string>
<string name="certified_not_yet_created">Certified, not created yet</string>
<string name="created_you_are_not_a_member">Created, you aren't a member</string>
<string name="a_subgroup">A subgroup</string>
<string name="parent_group">Parent group</string>
<string name="the_group_this_one_is_a_subgroup_of">The group this one is a subgroup of</string>
<string name="run_by_members">Run by %1$s members</string>
<string name="new_subgroup">New subgroup</string>
<string name="what_is_the_subgroup_called">What is the subgroup called?</string>
<string name="who_runs_the_subgroup">Who runs the subgroup?</string>
<string name="a_subgroup_needs_at_least_three_admins">A subgroup needs at least three admins, you included, so pick at least two more people.</string>
<string name="anyone_in_this_group_can_run_a_subgroup">Anyone in this group can run a subgroup, whether or not they administer this one.</string>
<string name="has_no_key_package_yet">Has no key package yet</string>
<string name="admin_of_this_group">Admin of this group</string>
<string name="you_coordinate_this_subgroup">You coordinate this subgroup</string>
<string name="start_the_key_ceremony">Start the key ceremony</string>
<string name="checking_who_can_be_added">Checking who can be added…</string>
<string name="the_parent_group_has_certified_this_subgroup">The parent group has certified this subgroup</string>
<string name="the_parent_group_could_not_certify_this_subgroup">The parent group could not certify this subgroup</string>
<string name="the_parent_group_is_certifying_this_subgroup">The parent group is certifying this subgroup — %1$s of %2$s admins have to sign</string>
<string name="before_the_subgroup_exists_its_parent_signs">Before the subgroup exists, its parent signs for it. That signature is what lets anyone check where this group came from.</string>
<string name="ask_the_parent_group_to_certify">Ask the parent group to certify this subgroup</string>
<string name="create_the_subgroup">Create the subgroup</string>
feat(subgroups): put the ceremony that needs you at the bottom of the chat A NIP-17 room already showed the standing "waiting for your signature" notice under the newest message -- `ProposalsAwaitingYouNotice` is transport-agnostic and reads `FrostSigningSession` by room. What it never covered is the other thing a room can owe somebody, which in a NIP-17 room is the main thing: a ceremony. That gap matters more here than the signing one does. A ChillDKG cannot finish until **every** member has taken part, so one member not finding their request stalls everyone indefinitely -- and the only way to find it was to scroll the transcript to its request line, past whatever else the room has been used for. Three subgroups on the connected devices sat at 1 of 3 host keys for exactly that reason. `CeremoniesAwaitingYouNotice` sits beside the signing one, first in the reversed layout so a room owing both puts the ceremony nearest the composer -- until a ceremony finishes there is no key to sign anything with. **It covers two different kinds of owing, and the second has no gate behind it.** A participant is owed an approval, read through the same `pendingApproval` the ritual screen uses so the two cannot disagree. The member who *opened* it is owed something the protocol has no gate for: a ceremony reaching COMPLETE finishes nothing on its own -- the group has a key and somebody still has to get its state signed and create the room -- and that somebody is whoever opened it. Nothing else in the app would ever say so, which is what the coordinator was missing. `roomAwaitingCreation` is how it knows when to stop: the room a finished ceremony's key derives either exists or does not. Asking that rather than keeping a flag means the notice cannot become permanent furniture in every room that has ever held a ceremony. **It reads every ceremony in the room, not the newest.** A room holds more than one the moment a subgroup's admins are the whole group, and the one that wants you is routinely not the one that happened last -- that is what buried 2.0 and 2.1. The notice opens the ceremony it names, by session id, through the same `onOpenSharedKey` the transcript's own lines use since `0b65d702`. Unlike the signing notice it opens the ceremony rather than a queue: there is no queue of ceremonies, and with several the count is shown and the newest opened. Eight strings in the catalogue in sentence case, each step worded the way its approval screen words it so a member is not asked twice in two vocabularies. `dkgRepository` is threaded to `ChatMessageListViewModel` through the messaging screen, the home pane and the nav host. 397 common tests, 718 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:14:11 +02:00
<string name="the_key_ceremony_needs_you">The key ceremony needs you</string>
<string name="the_subgroups_key_ceremony_needs_you">The subgroup's key ceremony needs you</string>
<string name="ceremonies_need_you">%1$s key ceremonies need you</string>
<string name="join_the_ceremony_by_publishing_your_key">Join it by publishing your device's key</string>
<string name="send_your_contribution_to_the_key">Send your contribution to the key</string>
<string name="check_the_combined_result">Check the combined result and confirm it</string>
<string name="the_group_has_its_key_finish_setting_it_up">The group has its key — finish setting it up</string>
<string name="open">Open</string>
feat(groups): the group's own nostr identity, and the two ways a member could forge it A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`, where those are one value -- so every group in this app has had a nostr identity from the moment it had a key, and has never had a way to say anything about it. This adds the two statements that identity is made of: a kind:0 saying who the group is, and the four relay lists saying where it can be found. Both are signed by the group's quorum like everything else it says. Two sections on the group's detail screen, between the signing key and the library, and two editors behind them. Neither editor saves: what leaves them is a `FrostSigningSession`, and the profile changes on every member's device at once when enough members sign, or not at all. **A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.** `Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a `NostrEvent`: no member sent it, it never travelled the wire as itself, and the outbound pipeline re-authors rumors as their sender and would strip the group's signature off -- which is exactly the position `GroupSignedEvent` exists for. So `GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are readings rather than rows. Nothing new is stored: `FrostSigningManager.complete` already files every signed event before it applies one, so both readers had their data before this change and neither adds a table, a migration or a DAO method beyond the two existing kind-scoped queries. **Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust model.** The author has to be the room, the id has to be the hash of the fields beside it, and the signature has to verify -- checkable from the row alone, with no ceremony or key state to consult. A kind:0 that merely arrived in the room is not the group's profile; a relay list filed against the room by another group is not the group's relay list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's signature, a forged author and three kinds of rubbish in the signature field are all tested against the code that actually verifies rather than against a mock of it. **The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft did not.** Every kind the group signs needs an arm there or it falls through to `unsupported` and puts raw JSON in the transcript -- so a profile update would have appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot tell them apart: a completed signing session, where the author is the room by construction, and an arriving inner event, where it is whatever the sender wrote. A rumor carries an empty signature and any pubkey its sender likes, so the relay arm's original author-only check would have let one member write "the group signed its general relay list" into the room's transcript with no group involved. It now builds a `GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list passing through the room fails the author half and gets no line at all, which is right -- it is theirs, not the group's. **`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types. The profile and the relay lists are the same kind of statement and the transcript does the same thing with both -- a `RitualNotice`, answered and settled, because by the time the line exists a quorum has signed and there is nothing left to do about it. A type missing from that set renders as a chat bubble, silently, looking exactly like a member having said "The group is now called Translation collective"; that is why it is a set with two members rather than two checks. **Relay lists: four sets, and the two that were left out are the interesting part.** General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked (NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not here even though this app writes one for every person: a key package is a device's offer to be added to an MLS group, and a group has no device and joins nothing, so a group advertising where its key packages live would point at an address that is permanently empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason below. **A group signs and cannot decrypt, so every list is written in public tags.** NIP-51 puts a relay list's entries in the encrypted half by default, and a blocked list especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to the author's own key and there is no ECDH for a FROST threshold key here. That rules out 10012 entirely -- its list is only ever private -- and it means the group's blocked list is public where a person's client would keep it secret. Said in the code, and said on the Blocked tab where somebody is about to act on it. **The editor proposes every list that changed at once, which is the deliberate departure from the interface it copies.** Wisp publishes the tab in front of you, because a person signs for themselves and there is nothing to coordinate. Here each signature costs a quorum's attention, and four sessions for one sitting at one screen would ask for it four times over what is plainly one decision. `changedSets` is what keeps that honest, and it is the piece with a quiet failure on both sides: too eager and every visit re-signs four untouched lists, dating a decision the group did not make; too shy and an edit is dropped on a screen that said it had been sent. It compares in order, because a relay list is written in the order it is read back and a member who moved a relay to the front meant to; it counts a seeded first-time list as a change, so a group whose editor filled itself in will actually send it; and it counts an empty unagreed set as no change, so opening Blocked and pressing the button does not put a quorum's signature over silence. Nine cases in `EditGroupRelaysViewModelJvmTest`. **A relay that is neither read nor write is a state NIP-65 cannot express**, so it must not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read` for read-only and `write` for write-only, and has nothing for neither -- what neither would mean is that the relay is not in the list, and removing it is the row's own button. The toggles refuse to turn off the last marker and `template` drops such a relay as a second line of defence, because a bare tag written for it would advertise it as *both*, which is the opposite of what was asked. The round trip is tested for all three markers at once: the ordinary case is the dangerous one, since "both" is encoded by the third element being *absent*, so a reader that dropped it entirely would look correct for a both-ways relay and silently promote every read-only one. **`ephemeral.mantra.press` is what a first-time group is seeded with**, for General, Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish, finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to talk to somebody that nobody in the group chose. **A profile edit builds on the group's last one; a relay list does not.** `template` for the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead of being dropped by it. Using `createNew` there would compile, pass any test that only looked at the six fields on the form, and quietly wipe the rest every time somebody fixed a typo in the group's name. A relay list is the opposite: it is one list, replacing it is the whole point of signing a new one, and carrying a tag forward would make removal impossible. Both are tested for the behaviour that would be silent if wrong. **The name is written to both `name` and `display_name`.** They are the two fields readers pick a name out of and they disagree at their peril: a group whose `display_name` came from some other tool and whose `name` was edited here would keep answering to the old one in every client preferring `display_name` -- an edit that visibly does not take. One field on the form, both keys in the event. **Blank clears, and that is the only way to unsay something a group has signed.** `MetadataEvent`'s rule for these arguments is that an empty string removes the key and null leaves it alone, so the form sends what its fields hold. Said on the screen, because a member emptying a field is entitled to know whether it will be ignored. **Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather than a key it can sign as, so it has no nostr identity and never will; an empty section there would promise something that is not coming. Every other room shows the sections and gates only the two edit buttons on holding a share of the key -- the profile and the relay lists are worth reading whoever is looking, and only a share-holder can open a session about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry points and the subgroup one, since it walks the room's ceremonies looking for a share and asking three times would do the same walk three times for one answer. **"Never agreed" and "agreed empty" are different states and the summary says which.** A set the group has never signed reads "not set yet"; one it signed empty reads "no relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and `GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so the screen has a row per set either way. **The editor's working copy is seeded from the screen, not from the loader**, and that is a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an empty editor while the app worked fine, which is the shape of bug that survives review because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy` is idempotent and fills only missing keys, so the effect that calls it can re-run without throwing away typing. **`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since a group has no `Profile` row to hand it and the picture and the name were all it ever wanted from one. The two existing overloads delegate to it, so there is one avatar rather than a second one for groups. **What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete` applies signed events locally and puts nothing on the wire, for the reason its comment gives. So the profile and the lists are visible to members and to nobody else, and the relay lists say where the group's work *should* go without anything yet sending it there. Publishing would mean a `NostrEvent` row for an event no member authored, which is an architectural decision and belongs in its own change. Said at the top of both readers so the next reader does not assume otherwise. **And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds that a validly signed old one replayed by whoever kept a copy is a statement nobody can refuse. These five kinds are in the same position, so a member who joins after a profile is signed will not be handed it in their catch-up. Adding them is a decision with its own trade and is not made here. 48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape `\'`, so the aapt spelling would render the backslash. 1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`, 41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@4c0ed0c1ceeca5aba461fbca314821befc12c583
2026-09-10 11:30:53 +02:00
<string name="signed_by_the_group_on">Signed by the group on %1$s</string>
<string name="relays">Relays</string>
<string name="relay_url">Relay url</string>
<string name="eg_wss_relay_example_com">eg. wss://relay.example.com</string>
<string name="add_relay">Add relay</string>
<string name="that_is_not_a_relay_address">That isn't a relay address. Relays start with wss:// and cannot be on this device.</string>
<string name="that_relay_is_already_in_this_list">That relay is already in this list.</string>
feat(groups): a broadcast button under each signed event, and the relays it goes to Until now nothing a group signed reached a relay. `FrostSigningManager.complete` files the batch as `GroupSignedEvent` rows and applies it locally, and the profile, the relay lists, the posts and the schemas are all read off those rows -- the docs on each said so, and the only way out was the copy button on the key-state sheet. This is the way out: a "Broadcast" button under every signed event in the identity block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits the relays it will go to, presses once, and reads what each relay answered. The raw JSON is at the bottom for anyone who would rather use another tool. **The send goes to the relay pool directly, not through the broadcast queue.** Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, which is durable and retried, and it is not used here on purpose. The queue hangs off a `NostrEvent` row, and a row for the group's event is more than a queue entry: the home feed selects by kind, so the group's post would appear in it as anybody's; and the sync loop offers every local event id to the relays it reconciles with, so the event would reach relays the screen never named -- which would make "edit the relays" a fiction. `GroupSignedEvent` already explains at length why it is not a `NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of `RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is and for the same reason: the manager cannot be stood up in a test. The cost is that a broadcast is only as durable as the button press. Each row says what its relay said, and a relay that did not answer is sent to again by pressing again. **Behind no gate.** Every other button in the block is hidden from a member holding no share of the key, because each proposes a signature by the group. Sending what the group has already signed takes no share: the signature is the group's whoever repeats it, and any member could paste the JSON into another client today. So the button is there for whoever is looking, the way the suggestion queue is. **Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's General list where it has agreed one -- its write relays, and nothing from the app's set beside them, since a member who edited the relays meant them -- and the app's publish set where it has not. A relay list also goes to the indexers, because a list sent only to the relays it names is circular; a curated schema also goes to the relays it names for its own replies, so a client reading suggestions there finds what they answer. Nothing goes to a relay the group has blocked. The seed happens once, so a list the member emptied stays empty. **Each relay's answer is shown in the relay's words.** The pool answers once per relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and any other error -- and the rows keep them apart, because "blocked: not on the allow list" is something a member can act on and a red icon is not. A relay still unanswered when the whole send runs out of time reads "No answer", which is not the same as refused. Answers are matched by host, since the pool names a relay by the URL it first opened a socket with. The relay-list rows get their button inside the shared card, under the row and at the end, because the four rows share one card and "under the event" has to mean under the row. An agreed empty list gets one too: a withdrawal is a statement, and relays still holding the old list need to hear it. The key-state event and the subgroup certificates get none -- `ChronicleManager` keeps the key state off even the members-only path, and a public relay is a bigger audience than that. `BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only seeding, the three answer shapes and the host matching, and that what goes on the wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest` presses the button against a fake pool and finds each answer under its relay with the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group with a profile, two agreed lists, a post and a schema, each under its own card, and finds them still there for a member with no share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@6ae5a9676eb01fcda49024caffff1a8bdc335831
2026-09-12 13:55:29 +02:00
<!-- Broadcasting one event the group signed to relays a member picks. -->
<string name="broadcast">Broadcast</string>
<string name="sends_the_event_exactly_as_the_group_signed_it">Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours.</string>
refactor(groups): take out the group's nostr identity and the curated lists, and keep broadcast unreachable Lines B, D and H of docs/curated-to-mantra.md, pulled from the Curated fork this morning, go again this afternoon: the group's nostr profile (kind 0), its four relay lists (NIP-65, NIP-17, NIP-50, NIP-51), its posts (kind 1), the curated schemas it publishes (31889), the suggestions it reads (31888) and the entries it accepts (31890). Eighty-two files, twelve screens, the `nostr/curated/` package, the readings (`GroupNostrProfile`, `GroupRelayList`, `GroupPost`, `GroupCuratedSchema`, `GroupCuratedEntry`, `CuratedSuggestion`), the `applyInnerEvent` arms for all eight kinds, the `ChatRepository` and `NostrRepository` reads that fed them, 226 strings, and every test that came with them. A translation collective has no list to curate and no reason to describe itself under a key no member controls, and five rows saying so on every group's screen were five rows about somebody else's product. **The pasted-event proposal goes with them, because it was theirs.** `GroupEventProposal.ACCEPTED_KINDS` was exactly kinds 0, 1, the four relay-list kinds and 31889 -- "the kinds the group's screen has a place for", in its own words -- so with the five rows gone it would have refused every paste. Keeping it as a generic "sign any event" was considered and rejected: the screen's whole argument was that a member goes to the row to see the event landed, and there is no row. The `ProposedEvent` summaries for the same kinds go too; the one test of its pre-existing fallback ("Event of kind N") is kept, as the only line of `ProposedEventTest` that was about code this repository had before the pull. **Broadcast stays, and nothing opens it.** `BroadcastGroupSignedEventScreen`, its route, view model, state and `BroadcastButton` are kept, on the decision that a way to send a group-signed event to relays is worth having against the day something wires a button in -- the button's only call sites were the five removed screens. Two things had to change for it to compile against a tree with no relay lists: `defaultRelaysFor(kind)` is now the app's own publish set for every kind, since the General list it preferred, the Blocked list it filtered by and the schema relays it widened to no longer exist; and `relayUrlOrNull` moves into the view model's companion from the deleted `GroupRelaySet`, unchanged. The hint on the screen says "the relays this app publishes to" rather than "where the group has said it lives". Its two tests are rewritten around the new seed: the screen test answers for the first two seeded relays and counts the rest as asked, and empties the list by hand to see the empty state, since the seed always has something in it. Deleting broadcast outright -- the tidier tree -- was the recommendation and was declined. **Every pre-existing file is back at its pre-pull content plus the kept lines' hunks, and nothing else.** Eleven files -- `ChatMessage.kt`, `NostrEventDao.kt`, `NostrEvent.kt`, `LocalChatRoom.kt`, `Member.kt`, `ProposedEvent.kt`, `ChatTranscript.kt`, `ProfileAvatar.kt`, the group screen's view model and state, and `m3-title-case.py` -- were touched by no kept commit and are restored from ba0830a3 byte for byte, so `inComparableGroups` is a private helper of the group screen again rather than a shared extension one deleted screen needed, and `ProfileAvatar` has one overload again. The rest were restored and had the kept hunks re-applied: the back button's three on `ChatRoomDetailScreen`, broadcast's `groupSignedEvent` read on the two chat repositories, and on the two nostr repositories the sign-in reads, by reverse-applying the queue commit's hunk. The check is `git diff ba0830a3 -- <file>`, which shows only those. Reverting the eight commits was rejected because the back button and the read-only identity work landed on top of them and would have conflicted in every one of the shared files; editing the current files by hand was rejected because it leaves residue that a diff against the base cannot distinguish from a decision. **The strings that went are exactly the ones nothing references any more and that the pull added.** Six strings were unreferenced before the pull and stay; the header sentence and one capitalised "Mantra" that the queue commit's join carried are prose, not feature, and stay too. The seven section comments that described removed blocks go; the broadcast block's stays. **The plan's third decision said "hide the two rows behind a constant".** That covered the curated rows and not the profile, relays and posts beside them, and the call was to take the whole identity block out. The record of what went and what stayed is the paragraph after the built table in docs/curated-to-mantra.md, with the seam it leaves for the next pull: an upstream commit that touches the identity block conflicts at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and is dropped. docs/README.md says the same in a sentence. The nsec and npub notes still name `EditGroupCuratedSchemaViewModel`; they are records of what was built upstream and are left as written. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:compileKotlinJvm, :composeApp:jvmTest (826 tests, from 1,039), :composeApp:testDebugUnitTest (413, from 530) and :composeApp:m3Audit, every budget met, 12 adaptive uses at the floor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:45:15 +02:00
<string name="the_relays_this_app_publishes_to_are_filled_in">The relays this app publishes to are filled in. Add or remove relays before sending.</string>
feat(groups): a broadcast button under each signed event, and the relays it goes to Until now nothing a group signed reached a relay. `FrostSigningManager.complete` files the batch as `GroupSignedEvent` rows and applies it locally, and the profile, the relay lists, the posts and the schemas are all read off those rows -- the docs on each said so, and the only way out was the copy button on the key-state sheet. This is the way out: a "Broadcast" button under every signed event in the identity block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits the relays it will go to, presses once, and reads what each relay answered. The raw JSON is at the bottom for anyone who would rather use another tool. **The send goes to the relay pool directly, not through the broadcast queue.** Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, which is durable and retried, and it is not used here on purpose. The queue hangs off a `NostrEvent` row, and a row for the group's event is more than a queue entry: the home feed selects by kind, so the group's post would appear in it as anybody's; and the sync loop offers every local event id to the relays it reconciles with, so the event would reach relays the screen never named -- which would make "edit the relays" a fiction. `GroupSignedEvent` already explains at length why it is not a `NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of `RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is and for the same reason: the manager cannot be stood up in a test. The cost is that a broadcast is only as durable as the button press. Each row says what its relay said, and a relay that did not answer is sent to again by pressing again. **Behind no gate.** Every other button in the block is hidden from a member holding no share of the key, because each proposes a signature by the group. Sending what the group has already signed takes no share: the signature is the group's whoever repeats it, and any member could paste the JSON into another client today. So the button is there for whoever is looking, the way the suggestion queue is. **Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's General list where it has agreed one -- its write relays, and nothing from the app's set beside them, since a member who edited the relays meant them -- and the app's publish set where it has not. A relay list also goes to the indexers, because a list sent only to the relays it names is circular; a curated schema also goes to the relays it names for its own replies, so a client reading suggestions there finds what they answer. Nothing goes to a relay the group has blocked. The seed happens once, so a list the member emptied stays empty. **Each relay's answer is shown in the relay's words.** The pool answers once per relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and any other error -- and the rows keep them apart, because "blocked: not on the allow list" is something a member can act on and a red icon is not. A relay still unanswered when the whole send runs out of time reads "No answer", which is not the same as refused. Answers are matched by host, since the pool names a relay by the URL it first opened a socket with. The relay-list rows get their button inside the shared card, under the row and at the end, because the four rows share one card and "under the event" has to mean under the row. An agreed empty list gets one too: a withdrawal is a statement, and relays still holding the old list need to hear it. The key-state event and the subgroup certificates get none -- `ChronicleManager` keeps the key state off even the members-only path, and a public relay is a bigger audience than that. `BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only seeding, the three answer shapes and the host matching, and that what goes on the wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest` presses the button against a fake pool and finds each answer under its relay with the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group with a profile, two agreed lists, a post and a schema, each under its own card, and finds them still there for a member with no share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@6ae5a9676eb01fcda49024caffff1a8bdc335831
2026-09-12 13:55:29 +02:00
<string name="no_relays_to_broadcast_to">No relays to broadcast to. Add one above.</string>
<string name="add_a_relay_to_broadcast_to">Add a relay to broadcast to.</string>
<string name="relay_sending">Sending…</string>
<string name="relay_accepted">Accepted</string>
<string name="relay_rejected">Rejected: %1$s</string>
<string name="relay_could_not_send">Couldn't send: %1$s</string>
<string name="relay_no_answer">No answer</string>
<string name="accepted_by_n_of_m_relays">Accepted by %1$s of %2$s relays.</string>
<string name="copy_raw_json">Copy raw JSON</string>
<string name="copied_the_raw_json">Copied the raw JSON</string>
feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence about alpha testing and becomes the screen: one field, two things it accepts, and the npub it signs as shown before anything is written. One field, because a user does not choose an input type -- they paste what they have -- and the two shapes are unambiguous: words have spaces, keys do not. CredentialParser is that decision as a pure function. Words go through MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed nsec or sixty-four hex characters decode to a PrivateKey and must pass isValid(). The two shapes a reasonable person might paste and that cannot work are refused by name rather than lumped in with garbage: an npub is PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey (NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is, and a capital is a paste artefact worth showing rather than silently fixing. Problems with the input stay on the field, as supporting text, while the prompt is still showing; problems after confirming -- the key was already here, the write failed -- are a state of the screen, through ErrorState with a retry back to the field. Four states, wrapped in ScreenStateTransition. Confirm shows the npub in full, in monospace, and which kind was recognised, with the one consequence that differs: a recovery phrase also restores the wallet, a bare key comes with none. The secret itself is never echoed. SignInToProfileViewModel.commit is the effect, and its order is the point. The secret is written to its store first -- IdentityWriter.writeNostrKey for an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a phrase, both injected as functions so the commit can be driven without a key store -- and only on success is the placeholder account planted with nostrRepository.signInToProfile. The placeholder before the write would send the user to fetch a profile they can never sign for; the write before the placeholder is what the nav host then does. Both writers already refuse a duplicate by nostr public key, so a wallet's own nostr secret pasted as an nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare. The nav host wires the route with the same tail the create flow uses after writeSeed: re-list identities, select the new one, go to startup with the form popped, so back does not return to a field holding a secret. Startup finds the identity, activates it, and NavigationViewModel takes it from the placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from both entrances. Strings: nineteen new, sentence case, including the refusals as sentences that say what to do instead. The six Torch-era sign-in strings nothing referenced any more are gone, and the landing caption no longer promises a remote signer this does not deliver. Tests. CredentialParserJvmTest is every row of the table in and out, all three spellings of NIP-06's vector -- words, nsec, hex, with and without the nostr: prefix, in either case -- asserted to land on the same public key, which is the equality the duplicate check rests on; and each refusal by name. SignInToProfileViewModelJvmTest records the effects of a commit and asserts their order and count: write then plant for both kinds, and no planting after a refusal or a throw. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885 tests) and :composeApp:m3Audit, which found no spacing literal or title-case string in the new screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@220616019e6466a3fb36a5bf2b482f20d93c01c5
2026-09-12 12:05:35 +02:00
<!-- Sign in: docs/nsec-sign-in.md, phase 4. -->
<string name="a_password_protected_key_ncryptsec_is_not">A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec.</string>
<string name="enter_a_recovery_phrase_or_an_nsec_to_sign_in">Enter a recovery phrase or an nsec to sign in.</string>
<string name="paste">Paste</string>
<string name="recognised_as_a_nostr_secret_key_no_wallet">Recognised as a nostr secret key. No wallet comes with it.</string>
<string name="recognised_as_a_recovery_phrase_it_also">Recognised as a recovery phrase. It also restores the wallet.</string>
feat(sign-in): an npub, recognised, confirmed as read-only, then written Phase 4 of docs/npub-sign-in.md. The field accepts a third thing. CredentialParser recognises npub1... and nostr:npub1... as SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix that name a point on the curve -- the counterpart of the isValid() a secret is checked with, since not every x coordinate has a point above it. PublicKeyOnly goes, and its string with it; an npub that does not decode, or names no point, is InvalidKey. Hex stays a secret: a private key and an x-only public key are the same size, the confirm step shows the derived npub so a public key pasted as hex is visible for what it is, and guessing would never fire when it mattered, since an x coordinate is almost always also a valid scalar. The test pins the vector's own public key, pasted as hex, landing on a different npub. The confirm step's third arm says what the user is about to get and not get, once, before the choice: this profile and the people it follows; messages closed and nothing sent; paste the nsec later to open it. The landing caption, the field's label and its placeholder name the third input, the last with "to look around" for a user who does not know the word read-only. The view model takes the third writer the way it takes the other two and commits through one shared write-and-map. signInToProfile is idempotent by pubkey. Until now nothing called it twice for one key -- the writers refused a second sign-in before it got there. Pasting the nsec of a key held read-only is the first path that signs in a pubkey whose account already exists, and a second placeholder would be two kind-0 rows for one pubkey, two accounts disagreeing about which is this one. A repository test signs in twice and counts one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@bfad1f39a2030ccf52cee7bf390bb9d68d33e016
2026-09-12 20:32:54 +02:00
<string name="recognised_as_a_public_key_you_will_see">Recognised as a public key. You will see this profile and the people it follows; messages stay closed and nothing can be sent. Paste the nsec later to open it.</string>
<string name="recovery_phrase_nsec_or_npub">Recovery phrase, nsec or npub</string>
<string name="sign_in_with_a_recovery_phrase_an_nsec_or">Sign in with a recovery phrase, an nsec, or an npub to look around</string>
feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence about alpha testing and becomes the screen: one field, two things it accepts, and the npub it signs as shown before anything is written. One field, because a user does not choose an input type -- they paste what they have -- and the two shapes are unambiguous: words have spaces, keys do not. CredentialParser is that decision as a pure function. Words go through MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed nsec or sixty-four hex characters decode to a PrivateKey and must pass isValid(). The two shapes a reasonable person might paste and that cannot work are refused by name rather than lumped in with garbage: an npub is PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey (NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is, and a capital is a paste artefact worth showing rather than silently fixing. Problems with the input stay on the field, as supporting text, while the prompt is still showing; problems after confirming -- the key was already here, the write failed -- are a state of the screen, through ErrorState with a retry back to the field. Four states, wrapped in ScreenStateTransition. Confirm shows the npub in full, in monospace, and which kind was recognised, with the one consequence that differs: a recovery phrase also restores the wallet, a bare key comes with none. The secret itself is never echoed. SignInToProfileViewModel.commit is the effect, and its order is the point. The secret is written to its store first -- IdentityWriter.writeNostrKey for an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a phrase, both injected as functions so the commit can be driven without a key store -- and only on success is the placeholder account planted with nostrRepository.signInToProfile. The placeholder before the write would send the user to fetch a profile they can never sign for; the write before the placeholder is what the nav host then does. Both writers already refuse a duplicate by nostr public key, so a wallet's own nostr secret pasted as an nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare. The nav host wires the route with the same tail the create flow uses after writeSeed: re-list identities, select the new one, go to startup with the form popped, so back does not return to a field holding a secret. Startup finds the identity, activates it, and NavigationViewModel takes it from the placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from both entrances. Strings: nineteen new, sentence case, including the refusals as sentences that say what to do instead. The six Torch-era sign-in strings nothing referenced any more are gone, and the landing caption no longer promises a remote signer this does not deliver. Tests. CredentialParserJvmTest is every row of the table in and out, all three spellings of NIP-06's vector -- words, nsec, hex, with and without the nostr: prefix, in either case -- asserted to land on the same public key, which is the equality the duplicate check rests on; and each refusal by name. SignInToProfileViewModelJvmTest records the effects of a commit and asserts their order and count: write then plant for both kinds, and no planting after a refusal or a throw. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885 tests) and :composeApp:m3Audit, which found no spacing literal or title-case string in the new screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@220616019e6466a3fb36a5bf2b482f20d93c01c5
2026-09-12 12:05:35 +02:00
<string name="signed_in">Signed in</string>
<string name="signing_you_in">Signing you in…</string>
<string name="that_is_not_a_recovery_phrase_or_a_nostr_key">That is not a recovery phrase or a nostr key.</string>
<string name="that_nostr_secret_key_is_not_valid">That nostr secret key is not valid.</string>
<string name="that_recovery_phrase_is_not_valid_check">That recovery phrase is not valid. Check each word and the order.</string>
<string name="the_key_could_not_be_saved_on_this_device">The key could not be saved on this device.</string>
<string name="the_words_or_the_key_never_leave_this_device">Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm.</string>
<string name="this_key_is_already_on_this_device">This key is already on this device.</string>
feat(sign-in): an npub, recognised, confirmed as read-only, then written Phase 4 of docs/npub-sign-in.md. The field accepts a third thing. CredentialParser recognises npub1... and nostr:npub1... as SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix that name a point on the curve -- the counterpart of the isValid() a secret is checked with, since not every x coordinate has a point above it. PublicKeyOnly goes, and its string with it; an npub that does not decode, or names no point, is InvalidKey. Hex stays a secret: a private key and an x-only public key are the same size, the confirm step shows the derived npub so a public key pasted as hex is visible for what it is, and guessing would never fire when it mattered, since an x coordinate is almost always also a valid scalar. The test pins the vector's own public key, pasted as hex, landing on a different npub. The confirm step's third arm says what the user is about to get and not get, once, before the choice: this profile and the people it follows; messages closed and nothing sent; paste the nsec later to open it. The landing caption, the field's label and its placeholder name the third input, the last with "to look around" for a user who does not know the word read-only. The view model takes the third writer the way it takes the other two and commits through one shared write-and-map. signInToProfile is idempotent by pubkey. Until now nothing called it twice for one key -- the writers refused a second sign-in before it got there. Pasting the nsec of a key held read-only is the first path that signs in a pubkey whose account already exists, and a second placeholder would be two kind-0 rows for one pubkey, two accounts disagreeing about which is this one. A repository test signs in twice and counts one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@bfad1f39a2030ccf52cee7bf390bb9d68d33e016
2026-09-12 20:32:54 +02:00
<string name="twelve_words_nsec1_or_npub1">Twelve words, nsec1… or npub1…</string>
feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence about alpha testing and becomes the screen: one field, two things it accepts, and the npub it signs as shown before anything is written. One field, because a user does not choose an input type -- they paste what they have -- and the two shapes are unambiguous: words have spaces, keys do not. CredentialParser is that decision as a pure function. Words go through MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed nsec or sixty-four hex characters decode to a PrivateKey and must pass isValid(). The two shapes a reasonable person might paste and that cannot work are refused by name rather than lumped in with garbage: an npub is PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey (NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is, and a capital is a paste artefact worth showing rather than silently fixing. Problems with the input stay on the field, as supporting text, while the prompt is still showing; problems after confirming -- the key was already here, the write failed -- are a state of the screen, through ErrorState with a retry back to the field. Four states, wrapped in ScreenStateTransition. Confirm shows the npub in full, in monospace, and which kind was recognised, with the one consequence that differs: a recovery phrase also restores the wallet, a bare key comes with none. The secret itself is never echoed. SignInToProfileViewModel.commit is the effect, and its order is the point. The secret is written to its store first -- IdentityWriter.writeNostrKey for an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a phrase, both injected as functions so the commit can be driven without a key store -- and only on success is the placeholder account planted with nostrRepository.signInToProfile. The placeholder before the write would send the user to fetch a profile they can never sign for; the write before the placeholder is what the nav host then does. Both writers already refuse a duplicate by nostr public key, so a wallet's own nostr secret pasted as an nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare. The nav host wires the route with the same tail the create flow uses after writeSeed: re-list identities, select the new one, go to startup with the form popped, so back does not return to a field holding a secret. Startup finds the identity, activates it, and NavigationViewModel takes it from the placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from both entrances. Strings: nineteen new, sentence case, including the refusals as sentences that say what to do instead. The six Torch-era sign-in strings nothing referenced any more are gone, and the landing caption no longer promises a remote signer this does not deliver. Tests. CredentialParserJvmTest is every row of the table in and out, all three spellings of NIP-06's vector -- words, nsec, hex, with and without the nostr: prefix, in either case -- asserted to land on the same public key, which is the equality the duplicate check rests on; and each refusal by name. SignInToProfileViewModelJvmTest records the effects of a commit and asserts their order and count: write then plant for both kinds, and no planting after a refusal or a throw. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885 tests) and :composeApp:m3Audit, which found no spacing literal or title-case string in the new screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@220616019e6466a3fb36a5bf2b482f20d93c01c5
2026-09-12 12:05:35 +02:00
<string name="use_a_different_key">Use a different key</string>
<string name="you_are_signing_in_as">You are signing in as</string>
feat(sign-in): ask the relays that would know, and say when none of them did Phase 5 of docs/nsec-sign-in.md. Not nsec-specific: a restored recovery phrase whose profile was never published, or a device offline at sign-in, sat on the same spinner. An imported nsec is the first path that hits it routinely -- many nostr keys are made in a client that never wrote a kind 0. Ask the relays that would know. The sign-in sync fanned its REQ out over Relays.DefaultDMRelayList, which is listOf(ephemeral): our own relay, alone. The right answer for a profile this app created; an identity that has lived on Damus for three years has never heard of it. SignInSync now holds the one definition three call sites want -- the kinds, the request builder and the bootstrap set, which is every indexer relay plus ours. Indexer relays exist to hold everyone's kinds 0, 3 and 10002; that is exactly what the sync asks for. Then follow the answer, once. When a level-0 sign-in request brings back the user's own kind 10002, the sync pump queues the same request at level 1 at the relays that list says they write to, less the bootstrap set already asked. The outbox model doing what it is for: the indexers know *where* the user publishes, and the user's own relays are where the rest of their lists are authoritative. Write relays, not read relays -- asking a user's inbox for their own events is the mistake the split exists to name. One hop per account per session, because every bootstrap relay that holds the list answers with it; and a level-1 request never re-enters, because past the user's own relays lies the feed, not the profile. Say when none of them did. A request went pending -> sent when its REQ was dispatched and sent -> processed only if an event arrived for it; a relay that answered with EOSE and nothing else left its row at `sent` for good, so "still searching" and "searched, found nothing" were the same row and the screen waiting on the sync had no way to say the second thing. The pump's finally block -- every exit: EOSE, CLOSED, the bounded timeout -- now records `complete`, conditionally in SQL on the row still being `sent`, because the event handler that writes `processed` runs in its own coroutine and can land after the subscription has closed, and a completion that overwrote it would turn "found" back into "found nothing". UnsyncedProfileViewModel reads that. Its one decision, `decide`, is a pure function of the account: a kind 0 indexed or a Profile row means the route is about to move, so still searching; any request still pending or sent is a relay that has not finished; only when every request has finished and nothing came is the answer not-found. The screen's not-found state offers two exits. Try again re-queues the bootstrap, and the new in-flight requests put the screen back to searching on their own. Set up a profile is the six-event bootstrap a fresh key gets, without a fresh key: setUpProfileForExistingKey writes the kind 0 *over* the placeholder row, under its own id with signedAt back to null, rather than beside it -- getLocalAccounts is every kind-0 row, and two for one pubkey would be two accounts disagreeing about which is this one. The rows change under observeLocalAccount, NavigationViewModel sees an unsigned kind 0, and the notary, which already holds this key, signs it. createNewProfile now builds its events through the same bootstrapUnsignedEvents. Tests. SignInSyncTest pins the bootstrap set (every indexer, ours, no duplicates, more than one) and the outbox reading of a relay list: write and unmarked relays in, read relays and malformed tags out, already-asked removed. UnsyncedProfileDecisionTest walks pending -> sent -> complete and asserts the answer flips only on the last, that a request answered with something other than a profile still counts as finished, that an arrived kind 0 is never not-found. SynchronizeNostrEventRequestCompletionJvmTest pins the conditional update against a real Room database: sent becomes complete, processed and pending are left alone. SetUpProfileForExistingKeyJvmTest runs signInToProfile then the set-up against the repository and asserts one kind-0 row, the same id, unsigned, carrying the name, with five events beside it. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (895 tests) and :composeApp:m3Audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@d61d3560a090c35b8002fa73d0e24b863002e33e
2026-09-12 12:16:06 +02:00
<!-- Sign in: the search that found nothing, docs/nsec-sign-in.md, phase 5. -->
<string name="enter_a_name_for_the_profile">Enter a name for the profile.</string>
<string name="it_may_be_new_or_it_may_live_on_relays_we">It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here.</string>
<string name="set_up_a_profile">Set up a profile</string>
<string name="we_could_not_find_a_profile_for_this_key">We could not find a profile for this key on the relays we asked.</string>
feat(recovery): the key behind an identity that has no phrase, and the way to forget it Phase 6 of docs/nsec-sign-in.md. KeyRecoveryScreen offered one backup, the recovery phrase, whose screen reads userWallet.words out of the seed map. For an identity signed in with a bare key there are no words, and the honest answer that screen would give is "this device holds no phrase for the profile". So: KeyRecoveryScreen branches on the active identity's kind. A mnemonic identity keeps the phrase option. A NostrSecret identity gets a nostr secret key option in its place, routing to NostrSecretRoute, with its own status line ("you said you stored it") and a header that no longer promises coins. The backup flags underneath are the same two per-identity preferences the phrase screen writes; they already mean "this identity's secret is not backed up" for whichever secret it is. NostrSecretScreen is RecoveryPhraseScreen with the word grid replaced by the nsec: hidden until revealed, revealed in monospace, selectable, with a copy action -- nobody transcribes sixty-three characters by hand -- the same two checkboxes, hidden again on leaving. The view model reads the key from nostr-keys.dat at reveal time, by the identity's public key, not off the active identity, so the screen's contract -- nothing secret held longer than it is shown -- is the phrase screen's. The disclaimer is a new string: the old one said "...and the funds in its wallet", and this identity has no wallet to warn about. Forget this key. An import needs an inverse, and this is the first real "sign out" in the app; ActiveProfileScreen's button still routes to a pending screen, and the general case stays there, because removing a seed is a wallet question with funds behind it. Behind a dialog that names the npub and says the profile stays on the relays, four effects in an order that matters: the key out of nostr-keys.dat and the identity's preference files off the disk (IdentityWriter.forgetNostrKey, which refuses a key that is not in the file -- a wallet's nostr key lives in the seed); the metadata entry hidden, since the metadata store has no delete and isHidden is what the selector filters on; the account's unsigned rows deleted (forgetLocalAccount -- the kind 0 that made it a local account and anything queued that can now never be signed, with the requests that hang off them cascading; published events and the profile cache stay, as anyone else's would); and only then the caller told, which re-lists identities and clears the active one so the navigation observer sends a null identity to startup. A failure at the first step leaves the rest untouched -- an identity the selector lists but nothing can open is worse than one that is still there. Tests. IdentityWriterJvmTest runs both bare-key writers against the jvm key store and a real directory: a key written once and refused the second time by public key, with its preferences file created; forgetting one key leaves the other and takes its preferences with it; a key that is not in the file is not forgotten. Its keys are fresh per test rather than fixed, because DataStoreManager caches each id's preferences in a companion object for the life of the process, and a fixed key was served the UserPrefs an earlier test had created in an earlier temp directory -- worth knowing about that cache. NostrSecretViewModelJvmTest records the four effects and asserts their order, and that a key that could not be removed stops the sequence at one. ForgetLocalAccountJvmTest, against Room, asserts the account and its cascaded requests go while an unrelated account stays. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (901 tests) and :composeApp:m3Audit. Replayed onto Mantra by docs/curated-to-mantra.md: KeyRecoveryScreen.kt: the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@e3cc23ae319b0ef6a7fb7a25c53a2f15d44cf720
2026-09-12 12:27:54 +02:00
<!-- Recovery for an identity with a key and no phrase, docs/nsec-sign-in.md, phase 6. -->
<string name="copied_the_nsec">Copied the nsec</string>
<string name="copy_nsec">Copy nsec</string>
<string name="could_not_forget_the_key_please_try_again">Could not forget the key. Please try again.</string>
<string name="could_not_unlock_your_key_please_try">Could not unlock your key. Please try again.</string>
<string name="mantra_will_delete_the_nsec_for_s_from_this">Mantra will delete the nsec for %1$s from this device. Anything not yet published is lost. The profile stays on the relays, and the key can be signed in again.</string>
<string name="display_nostr_secret_key">Display nostr secret key</string>
<string name="forget">Forget</string>
<string name="forget_this_key">Forget this key</string>
<string name="forget_this_key_question">Forget this key?</string>
<string name="i_have_saved_my_nostr_secret_key_somewhere">I have saved my nostr secret key somewhere safe.</string>
<string name="i_understand_that_if_i_lose_this_phone_and_my_key">I understand that if I lose this phone and my nostr secret key, I lose this profile.</string>
<string name="keep_this_key_safe_do_not_share_it">Keep this key safe.\nDo not share it.</string>
<string name="key_forgotten">Key forgotten</string>
<string name="no_identity_is_open_on_this_device_so_there">No profile is open on this device, so there is no key to show.</string>
<string name="nostr_secret_key">Nostr secret key</string>
<string name="remove_the_key_from_this_device_the_profile">Remove the key from this device. The profile stays on the relays.</string>
<string name="store_the_nsec_that_signs_as_this_profile">Store the nsec that signs as this profile. There is no phrase behind it: the key is the whole backup.</string>
<string name="the_nsec_is_the_key_that_signs_as_you_this">The nsec is the key that signs as you. This profile was signed in with it rather than made from a recovery phrase, so there is no phrase to write down and no wallet attached: the key is the whole of the backup.\n\nOnly you have it. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Store it somewhere safe that is not this phone. If you lose both the phone and the key, this profile is gone for good.</string>
<string name="this_device_holds_no_key_for_the_profile">This device holds no nostr secret key for the profile that is open. If it was made from a recovery phrase, the phrase is its backup.</string>
<string name="unlocking_your_key">Unlocking your key…</string>
<string name="you_have_not_backed_up_your_nostr_secret_key">You have not backed up your nostr secret key</string>
<string name="these_are_your_keys_keep_them_safe_no_wallet">These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone.</string>
<string name="you_said_you_stored_it">You said you stored it</string>
fix(ui): a back button on every pushed screen, and one spelling of it Eighteen screens drew a back arrow in their app bar's leading slot and twenty-two pushed screens did not. On Android the system back stood in for it and on iOS the edge swipe, but the desktop target has neither, and a screen reached by `navigate(...)` with no way to pop it is a dead end there: seven group forms, six chat screens, five DKG screens, and four screens with no app bar at all. **One widget, `NavigateBackButton`, rather than a nineteenth inline copy.** The eighteen that had the button spelled it four ways -- `Icons.Filled.ArrowBack`, the auto-mirrored one, `ArrowBackIosNew`, and a `"Back"` literal against a `stringResource`. The widget decides twice: the icon is the auto-mirrored one, because "back" points at the leading edge and the leading edge is on the right in an RTL locale, which is what `Icons.Filled.ArrowBack` is deprecated for; and the description is the catalogue's, because it is text. All forty-one sites use it now, the eighteen converted mechanically with the imports they no longer need dropped. **Each screen takes `onNavigateBack` and the host passes `popBackStack()`.** Hoisted rather than read from a controller in the screen, so every one stays previewable and testable without a nav host, which is how the twenty-two were fixed without a nav host in a single test. **Four screens had no bar to put it in, and got one.** `ChatRoomCreationScreen` is "New chat", after the button that opens it. `CreateProfileScreen` is "Create profile", and the two body headlines that repeated the name are gone, which is the shape `SignInScreen` beside it on the landing page already has. `WriteNewNoteScreen` is "New note" whether it is a reply, a quote or neither, one new string. The "coming soon" placeholder's bar is titled with the name of what was tapped. `AddMemberToChatRoomConfirmationScreen`'s bar had been commented out; it is back, titled "Invite new member" since the body already names who and which room. `NostrEventDetailScreen`'s repost and unknown-kind branches were the last two placeholders without a bar. **Two screens are reached two ways, and only the caller knows which.** Their callback is nullable, and null draws no button. `ChatRoomMessagingScreen` is a destination on a phone and the home screen's detail pane in an expanded window; in the pane the room list is beside the transcript and a button that popped would pop the home screen, so the pane passes null. `ImplementationPendingScreen` is pushed from "learn more" and "edit profile" and is also where the navigation observer lands with `popUpTo(0)` on an error; the host reads `previousBackStackEntry`, remembered at first composition because the departing screen reads it again after the stack has moved on. The DKG approval screens reuse their existing `onDone`, which "Not now" already called -- the bar makes the same leave reachable from the loading and error states, which had no other way off. **Left alone on purpose.** The three top-level destinations have the navigation bar, which phase 6 put there instead of app-bar icons. The onboarding and loading screens cleared the stack to get where they are and have nothing under them. `SearchScreen` and `SearchResultScreen` keep their `ArrowBackIosNew`: it is a search bar's collapse control in a `leadingIcon` slot, not a navigation icon. `NavigateBackButtonJvmTest` covers the two conditional screens by the description a screen reader would announce: present and popping when pushed, absent in the pane and at the root. The rule is recorded in CLAUDE.md beside the others a new screen has to follow. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (938 tests, 4 new) and docs/scripts/m3-audit.sh --check, all budgets met. Replayed onto Mantra by docs/curated-to-mantra.md: CuratedSuggestionListScreen.kt: taken as the original merge fedbe724 left it, this being the branch join; AcceptCuratedSuggestionScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts; BroadcastGroupSignedEventScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@0634487e120975156f5d3720e2d6ef9f82e38cf1
2026-09-12 13:55:42 +02:00
<!-- The bar on the note composer, which had no bar before it had a back button. -->
<string name="new_note">New note</string>
feat(identity): which profile opens, on launch and after a switch Phase 3 of docs/multiple-profiles.md. The startup screen learns to agree with the switch. The table that picks what to open is lifted out of the screen's remember into StartupChoice.resolve, where it can be read and tested, because its order was the whole decision and it was wrong in the one way a switch would hit: "!startWalletImmediately -> null" sat above "desiredWalletId != null", and nothing ever set the flag back to true. So after the first visit to the selector, every sign-in on that device landed on the selector instead of the profile it had just signed in. Two rows move: what a switch or a sign-in named goes above the user's earlier "show me the list", since it is the more specific instruction; and the single-identity row drops below it, since when both are set they name the same thing. The default is written. GlobalPrefs.getDefaultWallet has been read by startup since Phoenix and saved by nothing in this app, so a cold boot with two profiles was the selector every time with no memory of which one was open. setActiveIdentity now saves the id it activates -- every activation goes through it, startup for all three kinds and the two tails through startup -- so the default is always the profile most recently open. The two forget tails clear it, since the default is the one memory of an identity that would otherwise outlive it. Both writes are wrapped: a default that could not be recorded is a selector on the next boot, not a crash now. The screen reads the saved value as a default only when it names a wallet, which is the one thing left to decide at the call site. The startup screen's literals go to the catalogue, and "wallet" goes with them except in the one place a wallet is what is starting: "Starting wallet" is shown from StartupViewState.StartingBusiness, which only the branch with a wallet attached reaches, and stays. The rest become "Preparing profiles", "Opening profile", "Decrypting", "Loading preferences", "Unlock to continue" and "Could not load the profiles on this device"; the selector's title is "Choose a profile", and select_a_wallet goes. Tests: StartupChoiceTest in commonTest, one case per row and the two orderings this phase is for -- a desired id opens even after the user once asked for the list, and one identity with the list asked for still shows it. IdentitySwitchJvmTest gains the cold boot: activate A then B, and a fresh view model over the same directory resolves B without asking; forget the default, and it resolves nothing. Found on the way and recorded in the test: DataStoreManager caches the global preferences once per process, bound to whichever directory was current when the first test in the JVM asked -- the same trap the nsec plan recorded for user preferences -- so the test reads the default through the view model's own instance rather than one of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@00fc99699564d30ece5773deb1d97d031e47afbb
2026-09-13 00:43:21 +02:00
<!-- Several profiles on a device, docs/multiple-profiles.md. The startup screen's literals,
with "wallet" kept only where a wallet is what is starting. -->
<string name="choose_a_profile">Choose a profile</string>
<string name="could_not_load_the_profiles_on_this_device">Could not load the profiles on this device</string>
<string name="decrypting">Decrypting…</string>
<string name="opening_profile">Opening profile…</string>
<string name="preparing_profiles">Preparing profiles…</string>
<string name="starting_wallet">Starting wallet…</string>
<string name="unlock_to_continue">Unlock to continue</string>
feat(ui): the switcher, showing the nostr profile and what the device holds for it Phase 4 of docs/multiple-profiles.md. One pushed screen, and the startup selector brought up to match it. ProfilesRoute, reached from the profile tab's row -- "Switch profile", where it said "Change account" and went to the pending screen -- and drawn by ProfilesScreen: a top app bar with a back button, and WalletsSelector as its whole content with the open profile above the divider and the others below. Tapping another row is switchToIdentity and nothing else; the observer restarts the app as that identity through its lock gate, so the screen is never around to see the result. Tapping the open row does nothing. Three states, said so: loading while the list is read, which in practice is never seen; an unreadable key file, through ErrorState with a retry that re-lists; and the list. Empty cannot happen while something is signed in and the screen does not pretend it can. One column at every width. The row is offered to every kind: a read-only identity can leave for another profile the same way a signing one can. ProfilesViewModel joins the flows the app already holds -- the listing state, the identities, the active identity, the wallet metadata -- to the profile rows for the listed keys, through NostrRepository.observeProfilesOf, one map that moves when any of them does. Nothing in it writes. What a row shows changes. WalletsSelector showed "Default name" over a random emoji for every row, because nothing in this app writes the wallet metadata's name, and two profiles side by side both called Default name and told apart by a bech32 string is not a switcher. The row now takes the nostr profile -- humanReadableNameOrPubkey over ProfileAvatar, the widgets the profile tab draws itself with -- with the npub on the second line, and falls back to the npub over the metadata's emoji for a key the device has no kind 0 for yet: one just created, or a read-only one that was never found. And it says what the device holds for the profile before the tap: "Wallet" for a seed attached, beside the "Read only" that was already there, and nothing for the plain case of a bare key. It is the difference between a profile that can be signed out of and one whose sign-out is a wallet question, and between two profiles with the same name. The startup screen draws the same widget and gets the same map through rememberProfilesOf, so both lists look the same. The selector's dead globalPrefs parameter goes with the rewrite. Tests: ProfilesScreenJvmTest, on the unmerged tree, with a wallet-attached A open, a bare B and a read-only C -- A by its nostr name with its npub and "Wallet" under it, B and C by their npub with only C labelled, no "Default name" anywhere, the open one drawn above the others; tapping A calls nothing and tapping B calls the switch once with B's id; an unreadable key file is an error whose retry is counted; the bar has a back button. ReadOnlyEntrancesJvmTest asserts "Switch profile" for both kinds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@25464595f4a1155f4f2637cb5a71efe17e3cde1a
2026-09-13 00:50:37 +02:00
<!-- The switcher: the profiles on this device, and what the device holds for each. -->
<string name="loading_profiles">Loading profiles…</string>
<string name="switch_profile">Switch profile</string>
<string name="wallet">Wallet</string>
fix: sentence-case every UI string, settle the product name, and empty the dead catalogue Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is unambiguous: "All text, including titles, headings, labels, menu items, navigation components, app bars, and buttons should use sentence-style capitalization. ... Don't use title case capitalization." The tree was title case throughout. **100 occurrences across 60 distinct strings**, in two passes, and the second pass is the interesting one. The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or `contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter", "Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported zero and the app still had "Invite a Friend" on its first screen. Two holes. The pattern required every word after the first to be capitalised, so anything with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact", "Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the next line was invisible. A whole-file scan allowing lowercase articles found 19 more strings, 27 occurrences. **Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and titles of works, and title case is how those are written. The first audit swept them up and reported 67 offenders where the real number was 41, which is the kind of number that teaches a reader to ignore the tool. Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is written for whoever is reading logcat. **Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became "Lightning payment": M3's rule for a description is to name the purpose rather than the picture, and "bolt" is the picture. **The product has one name now, and it is Mantra.** The launcher label, the desktop window title, the landing screen and the package all said Mantra; the home screen's app bar said "Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed. `UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire to relay operators, so it is a network identity question rather than a content one, and a comment at the call site says so. **The two destructive actions now say what they do.** "Leave group" and "Delete group" are `TextButton`s that fire immediately, with no confirmation step and nothing stating the consequence. M3: "Tell users what will happen if they take an action and how they can undo it." Read out of the repository rather than guessed, because saying the wrong thing about a destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and nothing else. So: "Posts a line to the room saying you left, and lets you delete it from this device afterwards", and "Removes the room from this device. The messages stay on the relays and with the other members." The second matters most -- a button labelled "Delete group" with no qualifier invites the belief that the messages are gone, which is the opposite of true. **1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts -- and **nothing referenced any of it**. The tree's only two `stringResource` calls are both commented out, and one of them names an `R.string`, which does not exist in a Compose Multiplatform resource set at all. Keeping them made the file look like the app's catalogue while the app's actual 332 strings sat in composables. It now holds `app_name` and a note about what happens next. A trap for the next person, recorded in the file: the compose resources plugin reports an XML comment containing a double hyphen only as "XML file ... is not valid. Check the file content." XML forbids `--` inside comments, and this commit hit it while writing that note. **The audit's check is now a script, for the reason the second pass exists.** `docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three ways and reported success anyway, which is worse than not checking. **Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals themselves are the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
</resources>