Commit Graph

466 Commits

Author SHA1 Message Date
Kgothatso Ngako
65182ac892 refactor: route every MLS group restore through ChatRoom.toMlsGroup()
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>
2026-08-29 18:38:39 +02:00
Kgothatso Ngako
ea8b2b276a Remove hack because quarz didn't persist the secretTree in an older version. 2026-08-29 16:57:26 +02:00
Kgothatso Ngako
9b3044f6fb feat: let robust groups choose their own quorum
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>
2026-08-29 16:53:23 +02:00
Kgothatso Ngako
bf94ecbe76 feat: choose how a group is run before creating it
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>
2026-08-29 16:44:29 +02:00
Kgothatso Ngako
f21b13da07 Update quarts 2026-08-29 16:22:56 +02:00
Kgothatso Ngako
c5c4bc9ed6 feat: pick group members before creating a chat room
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>
2026-08-29 16:14:37 +02:00
Kgothatso Ngako
539babbfce build: consume secp256k1-frost-kmp and lightning-kmp-app as composite-build submodules
Add both libraries as git submodules and resolve them through Gradle
composite builds instead of remote publications, so local changes to
either library are picked up by the app build directly.

Submodules:
* secp256k1-frost-kmp (github.com/ac-cord-ac/secp256k1-frost-kmp) at
  2478403 -- BIP-327 FROTH/FROST signing over secp256k1, KMP.
* lightning-kmp-app (github.com/kngako/lightning-kmp-app) at 6745d88 --
  phoenix business logic on top of lightning-kmp-core. Both submodules
  carry a local commit aligning them with this build's AGP 9.1.1 /
  Kotlin 2.4.10 (AGP version must match across a composite build or the
  android variants fail attribute matching).

settings.gradle.kts:
* includeBuild() both submodule checkouts.
* lightning-kmp-app's project stays named ':library' (compose-resources
  derives its Res package from the project name), so an explicit
  dependencySubstitution maps the coordinate the app declares,
  fr.acinq.phoenix:lightning-kmp-app, onto that project.
* Drop the jitpack.io repository: it only served com.github.kngako,
  which is no longer consumed as a binary.

composeApp/build.gradle.kts:
* commonMain gains ac.cord.auxiliary:library:1.0.0 (secp256k1-frost-kmp,
  substituted by the composite build).
* commonMain replaces com.github.kngako.lightning-kmp-app:library (via
  JitPack) with fr.acinq.phoenix:lightning-kmp-app:1.0.0 (substituted by
  the composite build). lightning-kmp-core still comes from Maven
  Central.
* Remove the RestoreJitPackClassifier component-metadata rule and the
  javax.inject import: it only existed to repair the artifact
  classifiers JitPack drops from the apple metadata variants, which no
  longer applies.

gradle/libs.versions.toml:
* Remove the now-unused lightningKmpApp version and the
  com.github.kngako lightning-kmp-app catalog entry.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata             :composeApp:compileDebugKotlinAndroid
2026-08-15 17:07:31 +02:00
Kgothatso Ngako
086b2f4e70 Update dependencies 2026-08-15 15:42:34 +02:00
Kgothatso Ngako
2f06485ec5 Update to make use of lightning-kmp-appp 2026-08-02 20:43:30 +02:00
Kgothatso Ngako
e9510c4ea5 Remove phoenix code 2026-08-02 18:58:36 +02:00
Kgothatso Ngako
b17901973c Add lightning-kmp-app as a commonMain dependency via JitPack
The shared lightning/phoenix logic now lives in kngako/lightning-kmp-app,
which publishes a single `:library` module. JitPack serves a repository's
submodules under <GROUP>.<ARTIFACT>, hence the
com.github.kngako.lightning-kmp-app:library coordinate. The repository is
content-filtered to com.github.kngako so it is not consulted for anything
else -- an unfiltered jitpack.io entry gets asked about every dependency
that misses in mavenCentral, and each miss is a remote round trip.

The version is a commit rather than master-SNAPSHOT. For a -SNAPSHOT
version JitPack advertises a unique-snapshot maven-metadata.xml
(timestamp=<sha>, buildNumber=1) while serving the files under their
literal -SNAPSHOT names, so gradle derives library-<target>-master-<sha>-1
and gets a 404 on every artifact. Pinning a commit sidesteps the snapshot
machinery entirely and is reproducible; it needs bumping when the fork
moves.

That leaves the classifier. JitPack rewrites the version inside a
published .module file and drops the classifier while doing so, so both
the sources and the host-specific metadata variants of each apple target
come back naming library-<target>-<ver>.jar -- a file that does not
exist, next to the -sources.jar and -metadata.jar that do. The klib and
the aar are named without a classifier and so survive the rewrite, which
is why the android compilation resolves this dependency perfectly well
and only the metadata compilations fail. Every shared ios source set
resolves through those, so a component metadata rule puts the -metadata
classifier back. It is scoped to the two apple modules and to their
metadata variant by name, so it cannot disturb the klib artifacts.

Verified: :composeApp:compileCommonMainKotlinMetadata and
:composeApp:compileDebugKotlinAndroid both pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:46:00 +02:00
Kgothatso Ngako
7d893c7a79 Bug fix for when new member is added. 2026-07-28 11:50:55 +02:00
Kgothatso Ngako
62233f0fd8 Try to broadcast in specific order 2026-07-28 09:50:37 +02:00
Kgothatso Ngako
180b8c5aa5 Remove scaffolding 2026-07-28 09:30:35 +02:00
Kgothatso Ngako
6e13938478 Add and broadcast translation chunks version 2026-07-28 09:29:15 +02:00
Kgothatso Ngako
02b2c9a4f8 Add and broadcast translation artifact version
We might not want to do translation chunk scaffolding
2026-07-28 09:21:16 +02:00
Kgothatso Ngako
8de05742d0 Add and broadcast chunked chapters 2026-07-28 09:10:20 +02:00
Kgothatso Ngako
83f777a758 Add chat messages for mantra logic 2026-07-28 01:59:28 +02:00
Kgothatso Ngako
eefcbaa157 Bug fix for inner events. 2026-07-28 01:39:32 +02:00
Kgothatso Ngako
33c4d1000f Send artifacts inner events 2026-07-28 00:52:21 +02:00
Kgothatso Ngako
0c65f61ea2 Fix chat initiation between profiles created in the app
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>
2026-07-27 20:51:47 +02:00
Kgothatso Ngako
c0897645ae Merge branch 'claude/sweet-keller-9c933e' into artifacts 2026-07-27 01:23:14 +02:00
Kgothatso Ngako
3356e58c06 Fix notary observer lifecycle and robustness bugs
- 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>
2026-07-27 01:22:40 +02:00
Kgothatso Ngako
2259b25f3f Remember scopes and repositories in MantraNavHost
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>
2026-07-27 01:21:56 +02:00
Kgothatso Ngako
0503464f6a Fix profile creation bugs
- 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>
2026-07-27 01:09:50 +02:00
Kgothatso Ngako
5e92e7f613 Minor bug fixes 2026-07-27 00:52:22 +02:00
Kgothatso Ngako
57652b98e1 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts
# Conflicts:
#	composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt
2026-07-26 03:04:33 +02:00
Kgothatso Ngako
5621493b98 Open the new artifact after adding it
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>
2026-07-26 03:03:10 +02:00
Kgothatso Ngako
66aabc24e0 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:50:25 +02:00
Kgothatso Ngako
5973ab1ca5 Reorder translation detail: chapters first
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>
2026-07-26 02:50:06 +02:00
Kgothatso Ngako
1391462640 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:46:48 +02:00
Kgothatso Ngako
3370e354c3 Reorder artifact detail: chapters and translations first
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>
2026-07-26 02:46:16 +02:00
Kgothatso Ngako
c41c7083f5 Fix for the correct thread again 2026-07-26 02:42:31 +02:00
Kgothatso Ngako
b0c523c0f0 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:41:29 +02:00
Kgothatso Ngako
dc23295d6e Rename TranslationDetail screen family to TranslationArtifactVersionDetail
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>
2026-07-26 02:40:42 +02:00
Kgothatso Ngako
90c6194071 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:30:46 +02:00
Kgothatso Ngako
c494cf9e11 Add per-chunk translation editor
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>
2026-07-26 02:29:58 +02:00
Kgothatso Ngako
72f0233bed Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:19:56 +02:00
Kgothatso Ngako
2170f434dc Add TranslationChapter screen with side-by-side chunks
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>
2026-07-26 02:19:30 +02:00
Kgothatso Ngako
05e200319a Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 02:06:06 +02:00
Kgothatso Ngako
e2fe415a1a Add MantraTranslationArtifactVersion detail screen
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>
2026-07-26 02:05:55 +02:00
Kgothatso Ngako
f097d71237 Name a translation after its dialect
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>
2026-07-26 01:59:32 +02:00
Kgothatso Ngako
de75823426 Merge branch 'claude/admiring-mahavira-a862d1' into artifacts 2026-07-26 01:53:02 +02:00
Kgothatso Ngako
99f99fa959 Add the add-translation workflow from the artifact detail screen
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>
2026-07-26 01:52:15 +02:00
Kgothatso Ngako
6b07bcd78f Apply correct thread 2026-07-26 01:39:15 +02:00
Kgothatso Ngako
b2664bf7a5 Add ChapterDetailScreen showing chapter text and chunks
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>
2026-07-26 01:34:12 +02:00
Kgothatso Ngako
f8e4a5b158 Add the add-chapter flow with paragraph chunking
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>
2026-07-26 01:28:30 +02:00
Kgothatso Ngako
d671c7c300 Add ArtifactDetailScreen with chapters and translations
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>
2026-07-26 01:10:22 +02:00
Kgothatso Ngako
308a28c073 List locally stored artifacts on the ChatRoomDetailScreen
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>
2026-07-26 00:57:53 +02:00
Kgothatso Ngako
24a00f793a Add dialect selection/creation to the add-artifact flow
MantraArtifact requires a dialectId (FK), but the add-artifact screen had
no way to provide one. Let the user either reuse an existing source
dialect or create a new one inline.

- DatabaseMantraRepository.addDialect builds a DialectEvent, derives a
  MantraDialect with the real chatRoomId/userPublicKey, and persists it
  plus a MarmotInnerEvent rumor (kind = DialectEvent.KIND) for the
  outbound pipeline, mirroring addArtifact. getDialects exposes the chat
  room's dialects via a new MantraDialectDao query.
- MantraDialect.fromDialectEventTemplate mirrors the other models for
  consistent id derivation; DialectEvent.build now takes name/country/
  language (emitting NameTag/CountryTag/LanguageTag), and toDialectEvent's
  tag order matches build so the event id round-trips. Also fixes the
  DialectEvent.build / toDialectEvent phantom generics.
- AddArtifactViewModel loads the dialects on init and its addArtifact now
  takes existingDialectId: reuse it when set, otherwise create a new
  dialect (fields required only in create mode), then create the artifact.
- AddArtifactScreen shows a FilterChip row (one chip per existing dialect
  plus a "New dialect" chip); the name/country/language fields appear only
  when creating a new dialect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:42:59 +02:00