Commit Graph

29 Commits

Author SHA1 Message Date
Kgothatso Ngako
c5c89c8d52 feat(groups): a row per kind of signed work on the group's screen, each opening its own
The nostr profile, the relay lists, the posts, the curated schemas and the
curated entries were five sections of cards on the group's screen, every card
with a button under it, which made the screen as long as everything the group
had ever said, with its members and its subgroups somewhere underneath. Each
section is now a row -- a card with an icon and a chevron, the shape of the
signing key row above them -- carrying the one line a member wants at a
glance: the group's name and address, how many of the four lists it has
agreed, and a count of posts, schemas and entries. The row opens a pushed
screen holding what the section held, unchanged: the cards, the broadcast
under each signed event, the queue under each schema, and the gated ways into
the editors, which sit inside the empty state where there is nothing yet. The
paste stays on the group's screen at the end of the block, since it is about
the block rather than any one row, and the whole block is still absent in a
NIP-17 room for the reason it always was.

Five routes, screens, view models and states, each reading only what its
screen shows plus whether this device can sign. The broadcast button becomes a
widget, since five screens draw it, and inComparableGroups moves to the key
extensions beside shortened, with the doc comment that had been orphaned from
it. The section tests become a fixture object and six tests: one for the rows
-- their order, what each says, which route each opens, their absence in a
NIP-17 room -- and one per screen for what the sections' tests used to check.

Replayed onto Mantra by docs/curated-to-mantra.md: GroupNostrProfileSectionJvmTest.kt: deleted, as this commit deletes it upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@55664cc7d4
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
b5cd41cb3d 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@0634487e12
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
a204279e2a feat(groups): the entries the group has accepted, under the schemas on its screen
A section for the kind 31890s the group signed, read off GroupSignedEvent the
way the profile, the posts, the relay lists and the schemas are. The reading
gate is theirs -- the room as author, the room's real signature -- plus two of
its own: an entry is matched to the list it replies to by the coordinate in
its a root, and checked against that list's fields exactly as the queue checks
a copy off a relay, so what this section calls accepted is what the queue will
mark curated once the entry is broadcast and read back. Newest per coordinate,
since re-curating is a newer event under the same d.

Each card names the list, gives the queue row's glance of what the entry is,
credits the suggester, and dates the group's decision; under it, the same
broadcast button every signed event in the block has, gated on nothing. There
is no way to add one here, because an entry is accepted from the queue on the
schema's card above, and the empty state says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@732a4527c6
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
8d32a69bf3 feat(groups): accept a suggestion into the list, edited, as an entry for the group to sign
The queue's sheet used to end in the suggestion's JSON and a button to copy
it. Now it ends in the one thing a member can do about a suggestion: take it
into the list. That opens a form seeded from the suggestion -- an input per
field the schema asks for, enums as chips, one input per value where a field
repeats -- and a button that proposes a kind 31890 for the room's quorum to
sign, pointing back at the suggestion it came from.

The write side of the NIP is ported for it: CuratedEntryEvent.canonicalTemplate
follows bitcoin.mov's buildCuratedCanonicalTemplate tag for tag, and walks the
same field list as the reader, so what the form proposes is what the queue
reads back. The one deviation is that nothing is clipped on the way through; a
value the schema refuses is named under its input instead, by the queue's own
verifier, before a quorum is spent on it. The d is kept whatever field writes
it, so accepting twice revises one entry rather than making two, and derived
fields are kept as the suggester's client filled them in, since this app
derives nothing.

After the quorum signs, a 31890 has somewhere to land: the signing screen says
which entry and who suggested it, the transcript gets a line naming the list,
and the broadcast screen seeds the schema's relays, which the NIP makes a MUST
and which the entry only names by coordinate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@fcc19f9500
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
39b1c714b8 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@6ae5a9676e
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
b127647145 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@1e52fc8f25
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
e2295b3971 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@e3cc23ae31
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
347cf26d83 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@d61d3560a0
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
2e31daf117 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@220616019e
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
26de13b010 feat(groups): an unsigned event, pasted, and the promise that it lands where its kind says
The group's identity block has a typed editor for each thing the group signs
about itself -- the profile form, the post composer, the relay editor, the schema
editor -- and each builds one kind of event. This is the untyped way in: an
unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to
the quorum like anything else, and filed by kind once it is signed. A pasted kind
0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind
10002 the general relay list. The entry is "Propose event", last in the identity
block, behind the same share-holder gate as every button in it and absent from a
NIP-17 room like the block itself.

The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov
repo prints exactly the kind 31889 event it would publish, id, pubkey, signature
and all; until now the only way to get that list under a group's key was to
retype it field by field into the editor. Now it is pasted, checked, and signed.

**Where the event lands is decided by nothing in this change, and that is the
design.** `FrostSigningManager.complete` files every signed event as a
`GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists
and schemas off those rows by kind, each reader verifying the room's signature
for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the
profile form does, because by the time either is signed there is no telling them
apart. A second path -- a switch on kind that wrote the pasted event into the
right place -- would have been a second reading of the same rows, and two
readings of one signature drift. So the persistence half of this feature is a
test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1,
kind 31889 and kind 10002 each through a real FROST quorum signature, in the
shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`,
`GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out,
under the group's key and coordinate rather than the pasted one.

**What the paste says about its author and its time is ignored, and the screen
says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`:
what goes to `proposeSigning` is the kind, the tags and the content, and the
session resolves the room's key, hashes the id over it and stamps the time it
opens at -- which is what every typed editor does too. `created_at` in particular
had to go. Every kind here is replaceable or addressable, so a pasted timestamp
older than the group's last event would make the new one *lose* to the old on
every reader and the proposal would look as though it had done nothing. The
explanatory line under the byline names both facts, because a paste with a
`pubkey` in it is the one case where a member could reasonably expect otherwise.

**Only kinds the screen has a place for, and only events that place would
accept -- checked before the quorum is asked, not after.** A signature is the most
expensive thing this app does, and the readers refuse rather than repair: a kind 0
whose content is not a profile, a blank kind 1, a kind 31889 with no visibility
would each be signed, filed and shown nowhere, with nothing to tell the member
their quorum was spent on nothing. So `GroupEventProposal.read` makes every check
a reader makes, on the paste, and refuses with the reader's own reason -- the
schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the
schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of
kinds with a section on the group's screen: 0, 1, the four relay lists and 31889.
Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate
or a key state event has invariants a paste cannot be trusted to meet and a place
in the room's life that a form is not, and the nip30303 kinds have editors of
their own. A long-form article is a perfectly good event the group could sign; it
is refused because the line is "has somewhere to land", and the refusal names the
kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing
groups are entitled to do, and an empty relay list is accepted, since it is a
withdrawal the relays card shows as "no relays" rather than as nothing -- the same
two lines `GroupNostrProfile.of` and `GroupRelayList` already draw.

**The preview is the signing screen's own words.** Above the paste is what the
group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1
field" -- read live on every change, since a paste is a few kilobytes and one JSON
parse is cheaper than a debounce that would hide the answer for a beat. It is
`ProposedEvent.summarize` on the reading, not a description of this screen's own,
so that what a member reads before proposing is word for word what every other
member reads before signing. Two descriptions of one event would drift.

**And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay
lists.** The three features before this one added none for their kinds, so a
member asked to sign the group's profile has been shown "Event of kind 0" over a
line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A
profile is now said by its name and what it says about itself, with "An empty
profile" and "Unreadable profile" kept apart because a signer should know which; a
post by its words, the same call the translated-passage arm makes; a relay list
by which of the four it is and its hosts, or "No relays" for a withdrawal.
`ProposedEventTest` is new and pins all of them, the schema arm from the last
commit included, and that an unrecognised kind keeps its number -- refusing to
describe an event is better than describing it wrongly.

**The paste stays on every refusal.** A refused reading is drawn under the field
and the button, pressed anyway, is answered rather than ignored; a failed proposal
is a snackbar over the same paste. The JSON is the thing worth keeping, and a
member who pasted two kilobytes of schema should not have to find them again
because a visibility tag was missing. The field is set in a monospace face for
the same reason: it is JSON, and a bracket that does not line up is what a member
is looking for in it. `isActionPending` guards a double press, because a pasted
kind 1 accumulates like any other post and a second session over the same paste
is a second post on the record forever.

**The byline is the group's**, as on the post composer and for its reason: whatever
is pasted goes out in the group's name, and a paste naming some other `pubkey`
goes out in the group's name anyway.

23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and
what is dropped, junk named as junk, the accepted set, an unreadable profile
against an empty one, a blank post, a schema refused with the reader's reasons, a
relay list with or without relays -- and the four signed chains that are this
commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer.
`ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview,
the two refusals and the inert button for a member with no share, and two more
cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block
and take it away from a member with no share and from a NIP-17 room.

1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@392b90b8d5
2026-09-13 11:58:00 +02:00
Kgothatso Ngako
f8e5d3610d feat(groups): the lists a group curates, and the one thing an edit may not change
`334e6dd1` gave a group a way to speak in its own name. This is the way it asks:
kind 31889, a *curated schema event*, signed by the room's own key -- the definition
of a list anyone may suggest entries to and only that key may accept them into. The
protocol is the curated-list NIP written up in the bitcoin.mov repo (`docs/NIP.md`
there, with `curated-schema-events.md` as the guided tour), and this is its first
half in this app: the schema. Suggestions (31888) and canonical entries (31890) reply
to it and are not read or written here yet.

The section sits under Posts and closes the nostr-identity block, above Subgroups.
The profile says who the group is, the relay lists say where to find it, the posts
are what it says there -- and a schema is the other thing it publishes for strangers
to answer. A post is the group speaking; a schema is the group asking, for entries to
a list it will then curate by quorum under the same key. Everything in that block is
signed by that key and read by people who were never in the room, and the divider
that used to close it after the posts now closes it after these.

**The protocol is ported tag for tag from the reference module, and the fixture is
the NIP's own example.** `nostr/curated/CuratedSchemaEvent` reads and writes the
event; `CuratedSchema`, `CuratedField` and `CuratedFieldConfig` are what the tags
mean. The point of publishing a schema is that another client can drive its form
from it, so the one property worth pinning is that what bitcoin.mov published reads
here and what this app signs would read there. `CuratedSchemaEventTest` parses the
bitcoin.mov schema from the NIP verbatim -- nine `field` tags, the `require-any`,
the domain, the relay -- checks every field of it, and round-trips it through
`template` back to an equal schema. The field tag's seventh position is a JSON blob,
and a key this app does not know is kept in `CuratedFieldConfig.others` and written
back, because other clients legitimately add their own and an edit here must not
strip what one of them meant.

**Rejected rather than repaired, with the one repair the NIP requires.** A schema is
what suggestions are checked against, so a reader that patched a broken one would
be accepting entries against a form nobody signed. `CuratedSchema.problems` is the
NIP's rejection list and nothing more -- a missing identifier, title, name or
description, an over-long one, a visibility that is absent or not one of the three
words, no `field` tags at all, a picture that is not https, a domain that is not a
hostname, a relay that is not a relay -- and it is the *same* list whether the schema
was typed into the editor or read off a relay: the editor refuses to propose what a
reader would refuse to show. The exception is `normalized`, which forces a field
writing to `d` and one writing to `title` into place, both required, because that is
the rule the NIP states so that no schema, wherever it came from, can talk a client
into accepting untitled or unaddressable entries. The "no fields" check runs before
that repair, on what the publisher said.

**Visibility is never guessed, so it is nullable.** A list that does not say who may
suggest to it is not one a client should act on, and the reference makes the same
point by reporting a missing visibility as a violation rather than defaulting it. An
enum with no "unknown" arm cannot carry that, so `CuratedSchema.visibility` is null
for a schema that does not say, `VisibilityMissing` is the problem it raises, and
`template` writes no `visibility` tag for a null one -- which every reader, this one
included, then refuses. The editor never produces one; only an event off a relay can
be missing it.

**One per identifier, newest wins.** The third ordering in this family. A profile is
one replaceable event and the newest wins; posts are not replaceable and accumulate;
a schema is addressable, so a group may curate several lists and each is its own
coordinate `31889:<room>:<d>`. `GroupCuratedSchema.newestPerListAmong` groups by
identifier, keeps the newest within each -- an edit is a newer event under the same
`d` -- and orders the lists most recently signed first, with the event id breaking
every tie so two devices reading the same events in different orders agree.

**An edit keeps the identifier, whatever the field says.** The identifier is the
coordinate every suggestion to the list replies to. Changing it would not edit the
list but start a second one with an empty queue and orphan the first's, so the
identifier field is read-only on an edit, says why under itself, and
`proposeSchema` takes it from the existing schema rather than from the field even
so -- a screen not drawing something is not a guard. A new list is "Add schema" on
the group's screen, which is why the section has both a per-card way in and a
button: each schema is edited on its own.

**A new list starts filled in, and with the group's own relays.** The two mandatory
fields are put on the form before anything is typed, rather than left for
`normalized` to add at signing time, because a form that showed an empty list and
then signed two fields would be lying about what it proposed. The relays are seeded
from the group's NIP-65 write relays -- where its canonical entries would be read
from -- falling back to `GroupRelaySet.General.defaults()`, which is this build's own
relay and the same thing a group opening the relay editor for the first time is
shown. A schema naming no relays is one whose suggestions could go anywhere, so the
seed is worth getting right; the NIP says the same, and a client is allowed to fall
back to its own list only when the schema names none.

**What the editor lets a group sign is held to the app's rule, not the NIP's.** A
`relay` tag may be `ws://` per the NIP and a schema read off a relay is accepted with
one; a relay *added here* goes through `GroupRelaySet.relayUrlOrNull` -- `wss://`,
not this machine -- because the group is about to put a quorum's signature on it for
strangers to publish to, which is exactly the argument that rule was written for.
The duplicate check compares normalized forms, since a relay signed into an existing
schema is kept as the group wrote it and the normalizer adds a trailing slash: the
same host with and without one is one relay. The first version compared strings and
would have let the second copy in; `EditGroupCuratedSchemaViewModelJvmTest` pins it.
Suggesters -- the `p` tags a closed or private list admits -- are accepted as an npub
(with or without `nostr:`) or 64 hex characters and nothing else, and only offered
when the visibility gives them something to be.

**A field is edited on a sheet of its own, and a mandatory one cannot stop being
one.** A field has a dozen settings, and thirteen of them inline would be a screen
nobody could find anything on. The sheet offers the positional parts first -- name,
label, placeholder, type, required -- and then only the config keys the chosen type
gives a meaning to: `min` and a numeric `max` for the three numeric types,
`options` for an enum, `https` for a url. A setting the event gives no meaning to
would be written into the schema and mean nothing, so when the type changes away
from one it is not kept. For a field writing to `d` or `title` the tag is pinned and
the requirement is on and disabled, because `normalized` would only put the field
back if the sheet let it be moved or relaxed, and offering a change the event will
not carry is worse than not offering it. A field name is one word and belongs to
one field, and a rename follows through to the `require-any` rules that named it,
since a rule naming a field that no longer exists would silently never be
satisfied. The rules themselves are a chip per field, selected for the ones in the
rule; one with fewer than two names says nothing and is dropped from the draft
rather than refused.

**The transcript arm returns null rather than `unsupported`.** The opposite call to
the kind:1 arm, for the reason that arm gives: a kind:1 is content somebody meant,
and a schema rumor from a member -- or one the room signed that no client could act
on -- is identity plumbing, the position the kind:0 and relay-list arms are in.
Parsed as well as verified, so the transcript and the group's screen agree about
which events are lists: `GroupCuratedSchema.of` refuses the same event both places.
The line names the list rather than describing the schema, because what a reader of
the room needs to know is that the group now curates -- or has changed the form for
-- a list called so-and-so, and the fields are on the group's screen.
`TYPE_GROUP_SCHEMA_SIGNED` joins `GROUP_IDENTITY_TYPES`, so it is drawn as a
`RitualNotice` and previewed like the other three.

**The signing screen says what it is.** `ProposedEvent.summarize` gets an arm for
31889, which the last three group features did not add for their kinds. A member
deciding whether to sign this is agreeing to take suggestions from whoever the
visibility admits and to be the one key that accepts them, and the event's content is
only the description -- so "Event of kind 31889" over a sentence would leave them
signing a list without being told its name, who may suggest to it, or how much an
entry asks for. The summary is those three.

**The sheet's switches carry their label as a description.** A screen reader
otherwise announces "switch, off" beside a sentence it has already read past. It is
also what the layout test reaches the switch by, which is how it was noticed.

**The heading is "Curated schemas"**, not the kind's name. The audit enforces
sentence case, and the sibling headings name the thing -- "Posts", "Relays" --
rather than the event.

**And the caveat that bites hardest here of all: nothing has reached a relay.** The
same limit `GroupPost` states, and a schema is the one statement whose *entire*
purpose is to be replied to by strangers. Until group-signed events are published,
a list defined here has no queue. The relays are signed into the schema ready for
that, which is also why the NIP puts them in the event rather than in a config file.

51 new tests. `CuratedSchemaEventTest` (22) works from the NIP's example event: the
read, the round trip, the tag order, a config key this app does not know, a
malformed blob costing a field its constraints and not its life, every rejection
rule, the description falling back to content, the mandatory-field repair, and
domain normalization. `GroupCuratedSchemaTest` (8) signs with real FROST quorums
through the same shape `FrostSigningManager.advance` runs -- the room's signature
reads, a stranger's does not, a forged author does not, and a signed-but-unusable
schema is refused -- and pins one-per-identifier ordering and the id tie-break.
`EditGroupCuratedSchemaViewModelJvmTest` (14) covers the seed, the identifier lock,
the mandatory fields, renames following through to rules, the pubkey and relay
rules, the normalized duplicate check, and the button proposing nothing for an
untouched schema. `EditGroupCuratedSchemaScreenJvmTest` (4) and three more cases in
`GroupNostrProfileSectionJvmTest` cover both editor states, the sheet offering a
type its own settings, and the section's place on the screen, its empty state, a
member with no share reading schemas they cannot edit, and its absence in a NIP-17
room.

1307 tests pass -- 838 in `:composeApp:jvmTest`, 469 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@930d37c81e
2026-09-13 11:58:00 +02:00
Kgothatso Ngako
e74592a48e feat(groups): what the group says out loud, and the one arm that must not swallow a message
`4c0ed0c1` gave a group a name and an address on nostr and nothing to say from
either. This is the third statement that identity is made of: kind:1s authored by
the room's own key, listed under the relay lists on the group's screen, with a
composer behind "Add post" that proposes the next one to the quorum.

The section sits under Relays and above Library on purpose. The profile says who the
group is, the relay lists say where to find it, and these are what a reader would
find there -- so they finish the identity block, and the library below them is the
group's *work* rather than its voice.

**A post is the group speaking; the transcript is a member speaking.** Those are
different things and the room already had the second. A room's messages are `ChatEvent`
kind:9 sent by whoever sent them; a post is kind:1 authored by the room's id, so any
reader can check the group said it and that no single member could have made it say so.
The composer's byline is the whole of that design: every other composer in this app
puts the writer's name over the draft because the writer is the author, and doing that
here would have a member writing what looks like their own note and finding the group
had said it. The byline is the group's profile picture and name, shown before a word is
typed.

**The kind:1 arm in `applyInnerEvent` is the dangerous one in this change, and it is
dangerous in the opposite direction to the last two.** Every kind the group signs needs
an arm there or a signed post lands in the transcript as raw JSON. But kind:1 is not
like kind:0 or a relay list: those are identity plumbing nobody sends into a room on
purpose, so the arms added in `4c0ed0c1` return null for one that is not the group's and
the event silently vanishes. A kind:1 is *content*. Somebody meant it, and it has
rendered as an `unsupported` event since long before any of this existed. So this arm
verifies, and on failure **falls through to `unsupported` rather than dropping the
event** -- which required naming that branch as a local `fun unsupported()` so one arm
can reach it deliberately instead of only falling off the end of the `when`. Getting
this wrong would have been this change quietly deleting messages it has no business
touching, and it would have looked like nothing at all.

**Verified, not merely attributed** -- the same rule the other two arms ended up at. A
rumor carries an empty signature and any pubkey its sender likes, so an author check
alone would let one member write "the group posted in its own name" into the room's own
transcript. `GroupSignedEvent.verifies` is what makes the line mean what it says.

**Posts accumulate; everything else in this feature replaces.** kind:1 is not
replaceable, so a second post stands beside the first rather than superseding it, and
every "newest wins" the profile and the relay lists are built on is wrong for these.
`newestFirstAmong` sorts rather than reduces, and `GroupPostTest` asserts three posts
survive all three orderings a query could hand them over in -- a reading that kept only
the newest would silently delete a group's entire history the first time it posted twice,
and would look like a feature until somebody noticed.

**Blank posts are refused twice.** The composer will not propose one, because a quorum
signing an empty note puts a post on the record that renders as nothing, cannot be told
from a broken one, and -- kind:1 not being replaceable -- cannot be un-said. And
`newestFirstAmong` drops one that reaches it anyway, since anything blank arriving from
outside this app is in exactly the same position.

**A reply is marked as one.** Nothing here writes replies -- `GroupPost.template` builds
a bare note, no `e` tags, no mentions, no NIP-14 subject, because every tag
`TextNoteEvent.build` can add describes a relationship to something else that a group's
first way of posting does not have and should not guess at. The mark is for an event that
arrived some other way: drawing a reply as an announcement would put half a conversation
on a group's page with nothing saying it was half.

**The card shows the whole note.** A post is short by construction and it is the one
thing on that screen which exists to be *read*; truncating it would mean tapping through
to see a paragraph. The composer grows into the screen for the same reason -- a
three-line box that hides what was written two sentences ago is a box nobody proofreads
in.

**No new `Post` row, for the reason there is no `Profile` row.** `Post` hangs off both
`NostrEvent` and `Profile` by foreign key and a group has neither, so this reads off
`GroupSignedEvent` like the profile and the relay lists. `FrostSigningManager.complete`
already files every signed event before applying it, so no table, no migration, and one
more kind-scoped query.

**Reading a post needs no share of the key; proposing one does.** A post is the one
statement here meant for people who were not in the room, so a member holding no share
reads the section like anybody else and simply gets no "Add post". Both gates are
`canEditNostrProfile`, which is `canSign` read once for what is now four entry points.

**And the caveat that bites hardest here: nothing has reached a relay.**
`FrostSigningManager.complete` puts nothing on the wire, so a group's posts are visible
to its members and to nobody else -- for a profile that is an inconvenience, for a post
it is most of the point. The relay lists directly above the section say where these
should go and nothing yet sends them. Stated at the top of `GroupPost` in those terms
rather than the neutral ones the other two readers use.

`TYPE_GROUP_POST_SIGNED` joins `GROUP_IDENTITY_TYPES`, so the transcript draws it as a
`RitualNotice` like the other two -- nobody said it, the group signed it. The line names
the post rather than quoting it: the post itself is on the group's screen where it can be
read whole, and a quote would be the same words twice with the second copy unable to say
who agreed to them.

10 new cases in `GroupPostTest`, signed with real FROST quorums through the same shape
`FrostSigningManager.advance` runs, and three more in the section layout test -- ordering
on screen, the empty state, and a member with no share reading posts they cannot add to.

1226 tests pass -- 787 in `:composeApp:jvmTest`, 439 in `:composeApp:testDebugUnitTest`,
13 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@334e6dd1cb
2026-09-13 11:58:00 +02:00
Kgothatso Ngako
590230f215 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@4c0ed0c1ce
2026-09-13 11:58:00 +02:00
Kgothatso Ngako
45cc80b538 feat(marmot): put a # in front of every group's name, and retire (#admins)
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
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
Kgothatso Ngako
5c893ab108 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
Kgothatso Ngako
6bd59e05fe 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
Kgothatso Ngako
b50b1762d4 feat: put a room's signing key first on its detail screen, and the signed event behind it
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
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
Kgothatso Ngako
113eda9f4d 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
Kgothatso Ngako
3382586501 Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
2026-09-08 09:31:16 +02:00
Kgothatso Ngako
64181bfcaa fix: say sign in is not available yet, instead of offering a flow alpha cannot finish
The sign in screen asked for an nsec or npub, walked through a confirmation
step and reported an error when the sign in failed. None of that can succeed
while the app is in alpha testing, so the screen is now the notice and nothing
else, centred in the window because the message is the only thing on it.

The screen no longer reads any state, so it takes no arguments and the
navigation host stops handing it the repository.

SignInToProfileViewModel, SignInToProfileUIState and SignInToProfileFormState
are left where they are. Nothing references them now, but they are the
implementation to restore when sign in ships, rather than something to write
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:28:11 +02:00
Kgothatso Ngako
5106f31332 Merge branch 'mantra' into claude/key-recovery-functionality-00e269
Brings in the eight phases of Material Design 3 conformance work, which
rewrote every screen this branch had touched. Both conflicts were in
files the M3 work reindented wholesale, so they were resolved by taking
that side and re-applying the key recovery change on top of it:

- ActiveProfileScreen: the entry that phase 4 had externalised as
  "Profile keys" is now `key_recovery` in the catalogue, and opens
  KeyRecoveryRoute rather than the pending-implementation route. Its
  icon takes `Decorative`, since the label sits beside it.
- MantraNavHost: the two new destinations were re-added inside the
  NavHost that now lives under MantraNavigationSuite.

The two new screens were then brought up to the conventions CLAUDE.md
now states: their 27 UI strings moved into the catalogue in sentence
case (the word index became a `%1$s` format string), spacing comes from
MaterialTheme.spacing, both content roots take readableContent(), the
error branch is the shared ErrorState -- with no retry offered where no
wallet is open, since retrying cannot help -- the checkbox row carries
minimumInteractiveComponentSize() now that the whole row is the target,
icons beside their own labels are Decorative, and the previews are
ConformancePreviews.

m3-audit.sh --check passes on every budget, and the string literal count
is back to the 39 the document quotes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00
Kgothatso Ngako
3f78eef1e8 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
Kgothatso Ngako
714354ae9a 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
Kgothatso Ngako
44bf2a01f0 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
Kgothatso Ngako
2117e22d48 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
Kgothatso Ngako
419504c982 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
Kgothatso Ngako
0304aca62a 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
Kgothatso Ngako
a10dc1a6f8 Add lightning-mobile support 2026-06-15 14:39:46 +02:00
Kgothatso Ngako
0652c6add4 Pass the torch... initial commit. 2026-03-23 01:41:39 +02:00