Files
mantra-kmp/composeApp/src
Kgothatso Ngako ea33217203 feat: build robust groups as NIP-17 instead of Marmot/MLS
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>
2026-08-29 18:39:43 +02:00
..