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>