Relays were answering with "too many concurrent REQs" and the emulator
log was full of it. Two independent defects, compounding.
## The subscription flow never completed
RelayPool.queryAsFlow returned a filtered view of the socket's hot
`incomingMessages`, with the terminating operator commented out:
return this.incomingMessages
.filterBySubscriptionId(id = subscriptionId)
// .transformWhileEventsAreIncoming()
A filter over a hot flow has no terminal event, so every collector
started for a sync request stayed alive for the life of the app --
accumulating one per request ever made, long after EOSE and CLOSE had
been sent. Neither pump's EOSE branch ended collection either;
`return@collect` only ends handling of the message in hand, as the
CLOSED branch's own comment already noted.
That is also why the log *flooded* rather than merely warning.
`filterBySubscriptionId` admits NoticeMessage on every subscription id
(a NOTICE carries none), so a single "too many concurrent REQs" notice
was delivered to every accumulated collector and logged once per
collector. Volume grew as notices x live collectors.
## Nothing bounded how many were open
Both pumps mark the request "sent" and then launch the subscription
detached:
nostrRepository.negentropySynchronizeRequestProcessed(request)
launch(Dispatchers.IO) { relaysSocketManager.negentropySync(...).collect { ... } }
The DAO query is `WHERE status = :status ... LIMIT 1`, so flipping the
row changes the head row, Room re-emits, and the collector body runs for
the next request while the previous subscription is still open. The
mutex covers only the setup block and publishSlots guards publishes, not
REQs, so the number of simultaneously open REQ/NEG-OPEN subscriptions
was bounded only by backlog depth.
## And back-pressure amplified itself
The negentropy ClosedMessage branch -- CLOSED being exactly what a relay
sends when refusing for too many concurrent REQs -- answered by queuing
the request again as a plain REQ. Each refusal therefore produced
another subscription. That branch also skipped the close its EOSE and
NEG-MSG siblings performed, leaking a slot precisely when the slot was
most needed.
## The fix, in the order it has to be applied
1. sockets/NostrIncomingMessageExt.kt gains isTerminalFor() and
completeOnSubscriptionEnd(), which emits the terminal message and
then completes. NOTICE is deliberately not terminal: with no
subscription id it reaches every collector on the socket, so treating
it as terminal would tear down every unrelated subscription at once.
2. RelayPool.queryAsFlow applies it, replacing the commented-out call.
3. SynchronizationViewModel gains subscriptionSlots =
Semaphore(MAX_CONCURRENT_SUBSCRIPTIONS = 4), acquired inside each
pump's launch before the socket call, so the backlog still drains but
queues on the semaphore rather than opening all at once.
4. Both pumps close in a `finally` under NonCancellable, replacing the
hand-rolled closes in the EOSE and NEG-MSG branches, so CLOSED and
NEG-ERR exits close too.
5. The negentropy CLOSED branch consults isBackPressure() -- NIP-01's
`rate-limited:` prefix plus the free-text forms relays actually send
-- and declines to retry, instead of answering back-pressure by
opening another subscription.
Order is load-bearing: capping slots before the flow could complete
would have deadlocked the pump on permits that never came back.
## A negentropy assumption that would have deadlocked it anyway
Capping slots nearly stalled the queue on a wrong assumption about what
ends a negentropy exchange. EOSE does not: the NegentropyMessage branch
reconciles ONCE, queues the ids it needs as a plain REQ, schedules what
the relay is missing, and stops -- this client does single-round
reconciliation. Waiting on an EOSE the exchange need not send would have
held all four slots forever. NegentropyMessage is therefore terminal
too, with the reasoning recorded at isTerminalFor().
Worth knowing separately, and left alone here: single-round
reconciliation may not converge on large sets, since negentropy is
normally iterative. A large divergence is closed by the plain-REQ
fallback rather than by negentropy itself.
Live collectors go from one per sync request ever made to at most four.
MAX_CONCURRENT_SUBSCRIPTIONS is the dial if sync feels slow -- relays
commonly allow around 20 per connection, so there is headroom.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Robust groups can now generate a FROST threshold key together. The
group's members are the participants, the room's creator is the
coordinator, and the whole protocol travels as gift-wrapped rumors on
the chat the group already has -- so there is no second transport to
build, operate or debug.
This is what the quorum has been reaching for since it was introduced.
Until now "t of n must approve" had no key to approve anything with;
ChillDKG produces one that no single member holds.
## Transport: seven rumor kinds (nostr/dkg/)
coordinator --[ 30310 proposal ]-> everyone
participant --[ 30311 host key ]-> everyone
participant --[ 30312 round 1 ]-> everyone ParticipantMsg1
coordinator --[ 30313 coord round 1 ]-> everyone CoordinatorMsg1
participant --[ 30314 round 2 ]-> everyone ParticipantMsg2
coordinator --[ 30315 certificate ]-> everyone CoordinatorMsg2
anyone --[ 30316 failure ]-> everyone abort + reason
These only ever exist inside a NIP-17 gift wrap, so no relay sees them
unencrypted and the replaceable semantics normally implied by the 3xxxx
range never apply -- which is why they can sit next to the app's other
private kinds (30300-30309) without meaning anything different.
Every message is addressed to the whole group, even the two the protocol
only needs the coordinator to read. NIP-17 wraps per recipient anyway,
ChillDKG treats the coordinator as untrusted by construction, and having
every member observe the ritual is what makes a progress UI possible
without a side channel.
`DkgSessionIdTag` is on every message: a group may abandon an attempt and
start another, and a straggler from the dead one must be dropped rather
than mixed into the live session. `DkgThresholdTag` rides the proposal so
every participant validates the same SessionParams -- disagreement on `t`
fails the session instead of quietly producing a weaker key.
## Persistence: inputs, not state (database/model/Dkg*, schema v2)
DkgSession deliberately stores no protocol state. Reading EncPedPop
confirms randomness enters the participant steps only through the passed
`random`/`auxRand` arguments (`simplSeed = taggedHash("encpedpop seed",
seed + random + encContext)`), so every ChillDkg step is a pure function
of inputs. Keeping the two 32-byte randoms plus the received messages is
therefore enough to recompute any intermediate state on demand, and the
opaque ParticipantState/CoordinatorState objects -- which have no
serialization API -- never need to be persisted at all.
That is not a micro-optimisation. A DKG cannot finish unless all n
members take part, and chat users close apps mid-round; recomputation is
what lets a ritual resume instead of forcing the group to start over.
DkgParticipantMessage is keyed (sessionId, participantPublicKey, kind) so
a redelivered message overwrites rather than accumulates -- relays
redeliver, and a duplicated round-1 message would hand the coordinator a
participant list of the wrong length.
Database goes to version 2 with an AutoMigration: v2 only adds tables, so
Room generates it. Schema 2.json is exported alongside.
## Driving it (managers/ChillDkgRitualManager.kt)
State machine driven entirely by arriving messages: persist, then ask
whether the ritual can move. Because every step is recomputable there is
no long-lived session in memory to lose, and processing is idempotent --
a redelivered message re-runs a step that has already been taken and
changes nothing.
The coordinator is a participant too, so it records its own outbound
messages locally: its round-1 message has to be in its own aggregation
alongside everyone else's. Being the room's creator buys it no authority
here -- ChillDKG's coordinator relays but cannot learn secrets or bias
the key -- only the job of aggregating.
Two decisions worth knowing:
* Host keys are DERIVED, not reused. `sha256("mantra/chilldkg/host-key/v1"
|| nostrSeckey)`. Reusing the nostr identity key directly was the
simpler option, but one secret serving two protocols means a flaw in
either reaches the other. Deriving from the same seed keeps it
recoverable from the wallet backup, which matters because ChillDKG
needs the host secret key to recover a session's outputs and asking
chat users to back up a second secret is how keys get lost.
* Participant order is a bytewise sort of the host public keys. ChillDKG
fails outright if participants disagree on ordering, and a sort is the
only order every device can derive independently from the same set.
Any ChillDkg exception ends the session for this device and is broadcast
as a 30316 so the rest of the group stops waiting, rather than leaving
every member on a spinner that will never resolve.
## Inbound (database/dao/NostrDao.kt)
One branch on the existing decrypted-gift-wrap dispatch, beside the
kind-14 and WelcomeEvent branches, handing ritual kinds to the manager.
## UI (ui/.../DkgRitualScreen.kt + view model, state, route)
Reached from chat room detail via "Shared Key", shown only for rooms with
no MLS state -- i.e. the NIP-17/robust ones. An MLS room has a single
admin and no group key to share, so the entry point would be a lie there.
The screen is a ladder of rounds with real counts ("3 of 5") rather than
a spinner. The unusual thing about a DKG, and the thing the UI has to get
across, is that it needs *everyone* at once; a count says who it is
waiting on, an indeterminate spinner says nothing. The coordinator gets
the start button, everyone else is told who they are waiting for, and a
failed ritual states plainly that no key was created and it is safe to
run again.
DkgSession.threshold finally gives the quorum somewhere to live. The
value chosen during group creation is still not persisted on ChatRoom,
so this screen re-asks with the same majority default rather than
inventing a different one; there is a TODO where that gap closes.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Not runtime-verified: exercising a DKG needs several devices exchanging
live messages, and the library's own vector suite needs JDK 21.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Robust rooms are now plain NIP-17 group chats: no MLS group, no key
packages, no invites, no admin. Convenient rooms are unchanged and stay
Marmot/MLS.
Everything needed to *run* a NIP-17 group was already here; what was
missing was any way to start one.
* Inbound already worked for N participants, not just a pair.
GiftWrapSeal derives the room id from the author plus every p-tag via
ChatRoom.deriveChatRoomId (a musig2 aggregate over the member set),
and NostrDao stands the room up with mlsGroupState = null and a
Participant row per p-tag, inserting placeholder profiles and queueing
a profile sync for anyone unknown.
* Outbound already worked too. sendChatMessage branches on
mlsGroupState == null into gift wraps p-tagged to every participant,
sealGiftWrapPayload wraps the payload once per participant, and
NotaryViewModel drives that loop at runtime -- so the path is live,
not merely present.
* The gap was creation. NostrNip17Dao.getOrCreateChatRoom only ever
inserts the active user as a participant (the peer is literally
commented out of its hexKeys set) and expects the room id to be handed
to it, which suits an inbound message and nothing else.
database/dao/NostrNip17Dao.kt
* New createNip17ChatRoom(): derives the id with the SAME
deriveChatRoomId the inbound path uses, so the creator and every
recipient independently arrive at the same room, and building the same
group twice is idempotent rather than duplicative. Then upserts the
room with mlsGroupState = null -- which is precisely the flag
sendChatMessage reads to choose gift wraps -- and a Participant row
for the creator plus every picked member.
repository/ChatRepository.kt, database/repository/DatabaseChatRepository.kt
* Expose it, with the same try/catch-and-log-null shape its
getOrCreateChatRoom sibling uses, plus the NO_OP stub for previews.
ui/view/model/SelectChatRoomTypeViewModel.kt
* createChatRoom() splits on the chosen type into createMarmotChatRoom()
and createNip17ChatRoom(). The convenient path is the previous body
verbatim. The robust path skips key package resolution, the 20-second
relay budget and the whole sequential invite loop, because NIP-17
membership IS the p-tag set -- there is nothing to invite anybody to,
and so no partial-failure case either.
* Removes the .copy(adminPubkeys = ...) added in bf94ecb. Robust was the
only thing that ever set a multi-admin list; with convenient now the
only MLS path, MarmotGroupData.bootstrap() already stamps
creator-only, so the override had become a branch that could not be
taken.
Trade-offs this bakes in, recorded here and in a TODO on the new path:
* The quorum has LESS meaning under NIP-17, not more. There is no group
state to change and so nothing to approve: membership is whatever a
message is addressed to, and a different member set is a different
musig aggregate, i.e. simply a different room. Under MLS there was at
least an admin_pubkeys list to hang FROST off later; here there is no
object for t-of-n to govern at all. The picker is still shown and
still has nowhere to persist to.
* Members do not learn the room exists until the first message is sent.
NIP-17 has no invite event -- the first gift wrap is the invitation.
* Robust rooms give up MLS forward secrecy and the sender ratchet. What
they gain is that there is no privileged member and no shared group
state to desync.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four places restored an MlsGroup from a chat room's persisted state, each
spelling out the same MlsGroup.restore(MlsGroupState.decodeTls(hex))
chain by hand. Three of them could not have called the shared helper
even if they wanted to: until ea8b2b2, ChatRoom.toMlsGroup() demanded a
pastGenerations count so it could replay the sender ratchet, which is a
question none of these callers had any business answering. Dropping that
parameter left the helper callable everywhere, so call it everywhere.
database/repository/DatabaseChatRepository.kt
* sendChatMessage() restores via toMlsGroup(). It only wants to know
whether the room is MLS-backed or gift-wrapped, which is exactly what
a null return says.
ui/view/model/AddArtifactViewModel.kt
* Same collapse: the ?.let { restore(...) }?.let { mlsGroup -> ... }
double-let becomes toMlsGroup()?.let { mlsGroup -> ... }.
database/dao/NostrDao.kt
* The fourth copy, and the one easiest to miss: it decoded the hex into
a mlsGroupStateByteArray temp and then restored under an explicit null
check on the bytes, rather than the ?.let the others used. Same
operation wearing different clothes. Now toMlsGroup() with a null
check on the group itself, which is what the branch actually meant.
None of these three encrypt, so none of them ever needed the generation
replay the old signature forced on them -- NostrDao is the inbound path,
AddArtifactViewModel only gates on the group existing, and
sendChatMessage writes a rumor for the outbound pipeline to encrypt
later. The only caller that does encrypt, encryptAndSendMarmotInnerEvent,
persists mlsGroup.saveState() immediately afterwards, so the ratchet
position now round-trips through storage on its own.
Unused imports go with them: MlsGroup and MlsGroupState from the first
two files, MlsGroupState from NostrDao (which still needs MlsGroup for
processWelcome).
MlsGroup.restore now appears exactly once in the codebase, inside
toMlsGroup() itself.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking Robust now asks how many of the admins have to approve a change
instead of silently assuming a simple majority. The majority is still
where the answer starts; it is just no longer the only one available.
database/model/types/ChatRoomType.kt
* approvalThreshold(adminCount) becomes defaultQuorum(adminCount): same
simple majority, but named for what it now is -- an opening position
rather than the rule.
* MINIMUM_QUORUM = 2. One approval is not a quorum, it is one person
acting alone, which is what CONVENIENT already offers.
* quorumRange(adminCount) = MINIMUM_QUORUM..adminCount -- never fewer
than two, never more admins than exist to approve. Because ROBUST is
gated at MINIMUM_ROBUST_GROUP_SIZE = 3, the range always holds at
least two choices, so the picker is never a control with nothing to
pick.
ui/view/model/SelectChatRoomTypeViewModel.kt
* The fixed approvalThreshold field becomes quorum: MutableState<Int>,
seeded from defaultQuorum(adminCount), alongside the quorumRange the
UI clamps against.
* setQuorum() coerces into quorumRange, so the value cannot escape the
bounds even if the buttons' own enablement is wrong, and freezes once
createdChatRoomId is set -- past creation the governance is already
stamped into the epoch-0 group context, exactly as selectChatRoomType
does.
* quorumExplanation() states what the choice costs day to day: "Any 3 of
you can approve a change -- the other 2 don't have to be around", and
at the top of the range "Every admin has to agree. If one of you goes
quiet, nothing about the group can change." Unanimity is a real
liveness risk and the user should read that before choosing it, not
after.
ui/composable/SelectChatRoomTypeScreen.kt
* ChatRoomTypeCard gains a trailing content slot, and the robust card
fills it with the new QuorumPicker -- but only while robust is the
selected type. Before that there is no decision to make and the
question would be noise.
* QuorumPicker is a stepper, not a text field: the range is small, both
ends are bounded, and a stepper cannot produce a value that has to be
rejected. The -/+ buttons disable at quorumRange.first/last and the
caption re-reads on every step.
* The robust card's own prose drops the hard number -- "approved by a
quorum of you" rather than "approved by 3 of the 5 admins" -- because
the number is now a choice rather than a fact, and the picker is the
thing that states it.
Not done, and called out in the TODO next to the admin list: the chosen
quorum is not persisted. It cannot ride in MarmotGroupData -- MIP-01's
wire format is fixed and an extra field would break byte-compatibility
with mdk/whitenoise -- so it needs a ChatRoom column and the Room
migration off schema version 1 that comes with it. Until then the quorum
is a stated intent sitting beside the admin list, in the same way the
t-of-n enforcement itself is still waiting on FROST signing over admin
changes.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a third and final group-creation step, after the member picker, that
asks whether the new group should be convenient (the creator is its only
admin) or robust (every member is an admin and a change needs a
threshold of them to approve it).
The flow is now:
ChatRoomCreationRoute (name + description)
-> SelectChatRoomMembersRoute (pick people)
-> SelectChatRoomTypeRoute (how it is run, then build it)
-> ChatRoomMessagingRoute
New: database/model/types/ChatRoomType.kt
* CONVENIENT / ROBUST, plus the two rules that go with them.
* approvalThreshold(adminCount) is a simple majority, so no half of the
group can move without the other.
* MINIMUM_ROBUST_GROUP_SIZE = 3 and isRobustAvailable(memberCount).
Below three a majority is not a meaningful check: at two admins every
change needs both of them, and at one the creator is deciding alone,
which is CONVENIENT under another name.
New: ui/composable/navigation/routes/SelectChatRoomTypeRoute.kt
* Carries activeUserPublicKey, name, description and the picked
memberPublicKeys. The governance choice feeds the epoch-0 group
context, so nothing can be persisted until it has been made and every
earlier answer has to ride along to this step.
* memberPublicKeys is a List<String>. Navigation 2.9.2 resolves that
through NavType.StringListType (NavTypeConverter maps
InternalType.STRING inside a collection onto it), so it needs no
hand-rolled encoding.
New: ui/view/state/SelectChatRoomTypeUIState.kt
* Loading/Loaded/Error, with Loaded carrying the picked members so the
screen can name them rather than echo hex keys.
New: ui/view/model/SelectChatRoomTypeViewModel.kt
* Owns the choice (selectedChatRoomType, convenient by default because
it is the option that always works) and the group creation and invite
round, both moved here wholesale from SelectChatRoomMembersViewModel.
* The choice is not cosmetic: it decides adminPubkeys on the epoch-0
MarmotGroupData. CONVENIENT stamps just the creator; ROBUST stamps the
creator plus every picked member, deduplicated (MIP-01 rejects
duplicates). MarmotInboundManager.processGroupMembershipChanges
already derives Participant.adminAt from exactly this list, so admin
status propagates to every member's device without further work.
* MarmotGroupData.bootstrap() still stamps the base metadata -- the
admin list is layered on with copy() -- so UI and CLI stay
byte-identical on everything else.
* selectChatRoomType() refuses ROBUST when the group is too small, and
freezes once the room exists: by then the choice is baked into the
epoch-0 group context and re-picking would change nothing.
* robustUnavailableReason spells out the shortfall ("Go back and add 1
more person"), pluralised here rather than in the composable.
* Known gap, left as a TODO next to the admin list: robust rooms get the
admin set but not the t-of-n approval itself. That needs FROST signing
over admin changes -- the same thing the existing "generate GID
through frost" TODO is waiting on. Until then every admin of a robust
room can still commit on their own.
New: ui/composable/SelectChatRoomTypeScreen.kt
* Two radio cards. Convenient explains that the creator acts alone and
that nobody can carry the group on without them; robust quotes the
real numbers -- "approved by 2 of 3 admins" -- computed from the
actual selection instead of leaving t-of-n abstract.
* Below MINIMUM_ROBUST_GROUP_SIZE the robust card renders disabled
(Card(enabled = false), disabled RadioButton, no click) and shows the
reason in the error colour. It stays on screen rather than vanishing,
so the option is discoverable and the fix -- go back, tick one more
person -- is obvious.
* Carries the bottom bar the members step used to own: the
create/progress/"Open chat" action and the partial-invite warning,
which now name members via displayNameFor() since this step only
receives keys.
SelectChatRoomMembersViewModel / SelectChatRoomMembersScreen
* Reduced to what their names say. Group creation, the invite round, the
key package lookup, the wallet flow and ChatRepository all move to the
type step; what stays is listing local profiles, ticking them, and
handing the keys on through SelectChatRoomTypeRoute.
* The up-front key package sync stays here, which is the point of doing
it early: it now has the whole type-selection step to land in before
anybody is invited.
* The action becomes "Next with N" and the empty-store copy no longer
offers to create the chat, because this step no longer can.
MantraNavHost
* Register SelectChatRoomTypeRoute. Opening the finished chat still pops
back through ChatRoomCreationRoute inclusive, so backing out of a new
chat lands where the user started rather than part-way through the
three creation steps.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Group creation was a single screen: name + description, then "Create
chat" minted the MLS group and dropped the user straight into an empty
room, with no way to bring anyone along except the existing
one-at-a-time invite path (chat room detail -> search member ->
confirm). Add a second step that lists the profiles already in the
local store and lets the user tick everyone the group is for, so a
group is created together with its members.
The flow is now:
ChatRoomCreationRoute (name + description)
-> SelectChatRoomMembersRoute (pick people, create, invite)
-> ChatRoomMessagingRoute
New: ui/composable/navigation/routes/SelectChatRoomMembersRoute.kt
* Carries activeUserPublicKey plus the name/description gathered by step
one, so nothing is persisted until the user confirms who is in.
* `description` is nullable with a default, and ChatRoomCreationViewModel
maps blank text onto null: an empty string is not something navigation
round-trips reliably as a path argument.
New: ui/view/state/SelectChatRoomMembersUIState.kt
* The Loading/Loaded/Error triple the other chat screens use. Loaded
carries the pickable profiles.
New: ui/view/model/SelectChatRoomMembersViewModel.kt
* initiate() lists nostrRepository.searchableProfiles() excluding the
active user -- purely local rows, no directory lookup. It also queues a
negentropy sync for every listed profile's KeyPackageEvent up front, so
the packages needed to actually add anybody have usually landed by the
time the user has finished ticking names.
* createChatRoom() moves here from ChatRoomCreationViewModel, unchanged
in substance: bootstrap MarmotGroupData into the epoch-0 GroupContext,
MlsGroup.create, then getOrCreateChatRoom keyed on the Marmot
nostr_group_id (not MlsGroup's own groupId). Minting the group here
rather than in step one is the point of the split -- backing out of the
picker no longer strands a member-less room in the chat list.
* inviteSelectedMembers() resolves the selected members' key packages
concurrently under one shared 20s budget (a single relay round trip for
the batch instead of one timeout per member) by observing
observeMarmotKeyPackageForPublicKey, then invites sequentially. The
room is re-read from the repository before every invite: inviteMember
advances the MLS epoch and persists the new state, so reusing the
snapshot taken before the previous invite would build the next commit
on top of state the group has already left.
* A member whose key package never shows up does not sink the group. It
is created without them and the screen names who was left out;
createdChatRoomId then turns the action into "Open chat" against the
room that already exists rather than minting a second one, and
toggleMember is frozen once the room exists so further ticks cannot
look like they will still be honoured.
New: ui/composable/SelectChatRoomMembersScreen.kt
* Checkbox list over Loaded.profiles, reusing the row shape of
SearchMemberToAddToChatRoomScreen (ProfileAvatar + name + about); both
the row and the checkbox toggle selection.
* BottomAppBar carries the running selection count and an
ExtendedFloatingActionButton labelled "Create chat with N", which
swaps to a progress indicator while the group is being built.
* An empty local store gets an explanatory state that still allows
creating the chat and inviting people later.
ChatRoomCreationViewModel
* Reduced to the details form. Group creation, the wallet keypair and
both repositories move to the picker, so factory() now takes no
arguments at all.
* Gains validateInput() (mirroring CreateProfileViewModel) so an unnamed
chat cannot advance, and selectMembers() to hand the collected
name/description to the next route.
ChatRoomCreationScreen
* Takes activeUserPublicKey from the route instead of the wallet flow
and the two repositories; the button becomes "Choose who to chat
with".
* Fix the "groupd" typo in the name placeholder.
MantraNavHost
* Register SelectChatRoomMembersRoute. Opening the finished chat pops
back through ChatRoomCreationRoute inclusive, so backing out of a
brand new chat lands where the user started rather than in the
half-filled creation form.
Known limits: the picker inherits ProfileDao's default LIMIT 21, so only
the first 21 local profiles are offered (the same cap the existing
add-member search already lives with), and a selected profile can only
join if their KeyPackageEvent is reachable -- contacts who have never
published one always land in the "couldn't be added" list.
Verified:
./gradlew :composeApp:compileCommonMainKotlinMetadata
./gradlew :composeApp:compileDebugKotlinAndroid
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A profile created in the app could never start a chat with another
profile created in the app: the peer always looked like it had no
metadata, no DM relay list and no MLS key package.
Root cause was in neither the chat code nor the relay list — nothing a
new profile signed ever reached a relay. Three queue observers used
`createdAt > :createdAt` with a `Clock.System.now()` default argument.
Kotlin evaluates that default once, at the call site, and Room binds it
for the life of the Flow; Instants persist at second resolution, so
every request enqueued in the observer's own start second (the whole
profile-creation burst) and everything left pending by a previous
session was permanently invisible. Nothing else drains those tables.
The failure was silent because `publishNostrEvent` stamps `signedAt`
and indexes the Profile in one transaction, satisfying the
ProfileLoaded branch before the UnannouncedProfile gate could be
reached — so a device-only profile looked fully announced.
Dropping the cutoff needs no schema change, so existing installs
self-heal on next launch: the stranded rows are still pending.
Also fixed, since they gate the same flow once events start moving:
- Broadcasts now always reach a terminal status (outer timeout plus
try/catch — `.catch` cannot see the suspend call that builds the
flow), interrupted ones are requeued once at startup, `OK: false` is
a failure rather than a recorded success, fan-out is bounded, and an
uncorrelated NOTICE no longer fails whatever publish shares the
socket. `take(1)` keeps the publish timeout from firing after a
success on a SharedFlow that never completes.
- CLOSED is parsed and handled, so a relay refusing a NEG subscription
falls back to REQ instead of waiting forever; NOTICE is parsed as
the two-element frame it is; negentropy timestamps use seconds, the
unit relays use.
- Both chat gates observe the peer's key package instead of reading it
once and latching a terminal error, and queue the sync they claimed
to be doing. Same-minute retries are no longer swallowed by IGNORE.
- Group rooms were keyed by the MLS group id instead of the Marmot
nostrGroupId (unrelated randoms, so neither side saw the other's
events); inviting a member wrote no Participant row, so the Welcome
produced no gift wraps, and discarded the post-addMember group state;
the invite reported success unconditionally.
- An inverted `containsKey` made the "missing peer DM relay list"
recovery a no-op, and the wrong RelayTag class wrote "r" tags where
NIP-51 relay lists expect "relay".
Verified with `:composeApp:compileDebugKotlinAndroid`, including that
Room's KSP regenerated the DAO impls without the frozen cutoff. Not yet
exercised against live relays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Observers now run as children of collectLatest keyed on the derived
nostr private key: previously every active-wallet emission spawned
four more eternal collectors on the app scope, and after a wallet
switch stale collectors kept signing with the old key (duplicate
signatures, gift wraps and key package bundles).
- Follow the keyManager StateFlow instead of snapshotting .value, so
the notary still starts when the key loads after the wallet emits.
- Guard per-item processing so one failing row logs instead of killing
the collector (and the queue) for the rest of the session.
- Derive the real nsecPassword for self-healed key package bundles via
a new PrivateKey.nsecPassword() extension instead of passing "".
- Fix a copy-pasted log tag in observeUnprocessedMarmotInnerEvents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The coroutine scopes, AuxDatabaseManager, and repositories were
created as plain vals in the composable body, so every recomposition
recreated them — leaking the old scopes' jobs and duplicating
repository instances. Wrap them in remember so they are created once
per composition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- platformWriteSeed (Android + iOS) invoked onSeedWritten twice on
success, doubling wallet switch/navigation and navigating from the
IO dispatcher; keep only the main-thread invocation.
- Gate profile event creation on the seed actually being written to
disk: writeSeed now reports success/error, so a failed seed write no
longer leaves orphaned unsigned events the notary can never sign.
- Stop rethrowing from createAccount's CoroutineExceptionHandler
(crashed the app); failures now show the Error state and reset the
pending flag instead of spinning forever. Add a re-entry guard
against double taps.
- Reset WritingSeedState after a completed attempt so retries are not
silently skipped, and record WrittenToDisk on success.
- Derive the nostr key with NodeParamsManager.chain instead of a
hardcoded Chain.Mainnet.
- Build the SearchRelayListEvent from DefaultSearchRelayList instead
of DM relays, and drop its empty privateTags array that caused a
pointless NIP-44 encrypted empty list in content.
- Compare pubKey, privateTags and signedAt in UnsignedNostrEvent
equals/hashCode so distinctUntilChanged cannot conflate rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a successful add-artifact, navigate to the artifact detail for the newly
created artifact instead of the implementation-pending screen. The ViewModel
now passes the created artifact's id (the returned inner event's id) to
onSuccess, and the screen opens ArtifactDetailRoute via the pop-inclusive
callback so the add form leaves the back stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Chapters section above the Details section on the
TranslationArtifactVersionDetailScreen so the actionable content leads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Chapters and Translations sections to the top of the artifact
detail screen and the descriptive Details and Versions sections to the
bottom, so the actionable content leads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rename the screen and its ViewModel, UI state, and route to reflect that
they describe a MantraTranslationArtifactVersion:
- TranslationDetailScreen -> TranslationArtifactVersionDetailScreen
- TranslationDetailViewModel -> TranslationArtifactVersionDetailViewModel
- TranslationDetailUIState -> TranslationArtifactVersionDetailUIState
- TranslationDetailRoute -> TranslationArtifactVersionDetailRoute
Files moved with git mv to preserve history; all references (nav host,
artifact detail screen) updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The translation column of the chapter table is now a TextButton (with a
ChevronRight) per chunk; tapping it opens an editor for that chunk.
- TranslateChunkScreen (route + screen/ViewModel/UIState) shows the full
original chunk and a text input prefilled with any existing translation.
Saving persists the translation and returns to a freshly-loaded chapter
table (popUpTo<TranslationChapterRoute>) so the update shows.
- MantraRepository.saveTranslationChunk builds a MantraTranslationChunk from
the entered text (content-hash id, index mirrored from the source chunk),
upserts it plus its MarmotInnerEvent rumor, and replaces any prior
translation chunk for the same source chunk (deleting the stale row and its
rumor) so there is exactly one per source chunk. New DAO queries getChunkById,
MantraTranslationChunkDao.deleteById, MarmotInnerEventDao.deleteById, and
repository getChunk.
- TranslationChapterScreen renders the translation cell as the button and
navigates to TranslateChunkRoute with the source chunkId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chapter cards in the translation detail now open a two-column chapter
translation screen.
- The screen loads the source MantraChapter's chunks paired with their
MantraTranslationChunks (by chunkId). Column one is the original chunks,
column two the translated chunks; the header row shows the original dialect
name and the translation dialect name. When a chunk has no translation
(missing or blank text), the original text is shown greyed out as a
placeholder in the second column.
- The ViewModel resolves the original dialect by walking chapter -> version
-> artifact -> dialect and the translation dialect via the translation
version. New DAO queries getArtifactVersionById and getTranslationChapterById
plus repository accessors getArtifactVersion/getTranslationChapter/getDialect.
- New TranslationChapterRoute + screen/ViewModel/UIState; TranslationDetail
chapter cards navigate to it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Translation cards in the artifact detail's Translations list are now
clickable, opening a translation detail screen.
- New TranslationDetailRoute + TranslationDetailScreen/ViewModel/UIState.
The screen shows the translation's details (name, dialect, visibility,
license, source version, author, created) and its chapters ordered by
index, each with translation progress (translated/total chunks, where a
chunk counts as translated once its text is non-empty).
- DAO queries getTranslationById, translation chapters by
translationArtifactVersionId, and translation chunks by
translationChapterId (validated by Room), exposed via MantraRepository
(impl + NO_OP).
- Extract the DetailRow composable (was private to ArtifactDetailScreen)
into a shared widgets/DetailRow.kt used by both detail screens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
addTranslation set the MantraTranslationArtifactVersion name to the source
artifact's name; use the selected dialect's name instead (visibility and
license still inherit from the artifact). Add a getDialectById query to look
the dialect up by id.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An Add Translation button in the artifact detail's Translations section
opens a workflow that requires selecting or creating a dialect (reusing the
chip picker + create-new-dialect fields), then scaffolds the translation.
- MantraRepository.addTranslation creates a MantraTranslationArtifactVersion
for the artifact's latest version and the chosen dialectId (inheriting the
source artifact's name/visibility/license), then mirrors the source
structure: a MantraTranslationChapter per chapter and a
MantraTranslationChunk (empty text scaffold) per chunk. Each created entity
also gets a MarmotInnerEvent rumor (kinds 30306/30308/30309). When a new
dialect is requested the ViewModel creates it first via addDialect.
- The three translation events' build methods now take real fields (fixing
their phantom generics); toXEvent tag order matches build and
fromXEventTemplate companions are added so the event ids round-trip.
- New AddTranslationScreen (route + ViewModel + UIState). On success it lands
on a freshly-loaded artifact detail via popUpTo<ArtifactDetailRoute>.
No MantraTranslation rows / translation text are created here — that is the
per-chunk authoring step, intentionally left out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a chapter in the artifact detail now opens a chapter detail screen.
- New ChapterDetailRoute + ChapterDetailScreen/ViewModel/UIState. The screen
shows the chapter's index and word/character counts, its original markdown
text (rendered as plain text — no markdown renderer available), and the
chapter's paragraph chunks (index, text, counts) with an empty state.
- DAO queries getChapterById and getChunksByChapterId (validated by Room),
exposed via MantraRepository.getChapter / getChunksForChapter (impl + NO_OP).
- ArtifactDetailScreen chapter cards are now clickable, navigating to
ChapterDetailRoute; MantraNavHost registers the route.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a chapter (markdown original text) to an artifact from its detail
screen, deriving word/character counts from the text and splitting it into
paragraph chunks.
- MantraRepository.addChapter attaches the chapter to the artifact's latest
version (index = existing chapter count), computes word/character counts,
and persists the MantraChapter plus a MarmotInnerEvent rumor
(kind = ChapterEvent.KIND). It then splits the markdown by paragraph into
MantraChunks, each with its own index/counts and a rumor
(kind = ChunkEvent.KIND). New DAO query getChaptersByArtifactVersionId.
- New press.mantra.compose.text.Markdown helpers: wordCount, characterCount,
splitParagraphs (blank-line separated).
- ChapterEvent.build / ChunkEvent.build now take the real fields (fixing the
phantom generics); toChapterEvent / toChunkEvent tag order matches build
and fromChapterEventTemplate / fromChunkEventTemplate are added so the
event ids round-trip, mirroring the other models.
- New AddChapterScreen (route + ViewModel + UIState) with a name field and a
markdown text field plus a live "words · characters · chunks" preview,
reached from an Add Chapter button in ArtifactDetailScreen (disabled until
the artifact has a version). On success it lands on a freshly-loaded
artifact detail via popUpTo<ArtifactDetailRoute>.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking an artifact in a chat room's library now opens an artifact
detail screen instead of the placeholder route.
- New ArtifactDetailRoute + ArtifactDetailScreen/ViewModel/UIState. The
screen shows the artifact's details (name, url, visibility, license,
dialect, author, created), its versions, and — walked via the artifact's
versions — the associated chapters and translations, each with an empty
state.
- DAO queries (validated by Room): getArtifactById, versions by artifactId,
chapters by artifactId (Chapter JOIN ArtifactVersion, ordered by index),
and translations by artifactId (TranslationArtifactVersion JOIN
ArtifactVersion). Exposed via MantraRepository (impl + NO_OP).
- ChatRoomDetailScreen navigates to ArtifactDetailRoute on artifact click;
MantraNavHost registers the route with a back-stack pop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Library section hardcoded "No artifacts exists". Load and render the
chat room's stored artifacts instead.
- MantraArtifactDao.getArtifactsByChatRoomId + MantraRepository.getArtifacts
expose a chat room's artifacts (newest first).
- ChatRoomDetailUIState.Loaded carries artifacts; ChatRoomDetailViewModel
gains mantraRepository and loads them in initiateChatRoomDetail.
- ChatRoomDetailScreen renders the empty-state text only when there are no
artifacts, otherwise a Card/ListItem per artifact (name, url, chevron to a
pending detail route). The screen takes mantraRepository, threaded through
the factory, the nav host (databaseMantraRepository), and the preview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>