three do not wait on it. The phases are kept as written because they are the
reasoning; where the implementation chose differently the table below says so:
| what the plan said | what it turned out to be |
|---|---|
| a new string, `no_profile_was_found_for_this_npub` | the sign-in screen's `we_could_not_find_a_profile_for_this_key` — "We could not find a profile for this key on the relays we asked." One string, two screens, one meaning, as the npub plan found with `use_a_different_key` |
| a new string, `open_chat` | it existed: `SelectChatRoomTypeScreen`'s. The build failed on the duplicate key, which is the catalogue doing its job |
| `findDirectMessageRoom` as "the rule `initiateNewChat` applied" | the rule's *intent*: a room whose members are exactly the two keys, or the one key when they are the same. The old size-one branch would have opened any room in which the peer sat alone, whoever the peer was; the new rule needs that lone member to be you, and the repository test pins a room the peer has with somebody else as not yours |
| `retry()` from `NotYetOnMantra` | from any `Loaded`: it re-asks the DM relays alone and touches the readiness only when it was `NotYetOnMantra`, so a retry never takes an "Open chat" back to "Checking" |
| the timeout "fires with a person on screen" | a flag, `relaysHadTheirTime`, because the profile can arrive *after* the timeout and has to read not-yet-on-Mantra rather than checking; cleared by a retry, and `@Volatile` because the timeout and the collect are different coroutines |
| the view model tested on `TestScope` virtual time | real time, as the other view-model tests: `Dispatchers.setMain(UnconfinedTestDispatcher())`, a poll under `withTimeout`, and the twenty seconds cut to three hundred milliseconds through the constructor |
| `initiate()` "runs from a `LaunchedEffect`" | and is idempotent besides: a recomposition that calls it again opens no second collect, which the view-model test asserts |
| a screenshot pass under `docs/material-design-conformance.md` | five PNGs from a throwaway `jvmTest` with `captureToImage`, looked at and deleted — the found, not-found, checking, not-yet-on-Mantra and existing-chat states, at a phone's width |
| Phase 2's screen with one repository | Phase 4 adds `chatRepository` to the screen, its host entry and its tests, as the plan said it would; the Phase 2 commit is the screen without it |
| a `Button` under the block, "because a FAB has no disabled state in M3 or in the API" | **superseded on the way in**: `curated` gained `ExtendedFab` while this was built — every screen's bottom-bar action, with an `enabled` that borrows the disabled colours, marks the node disabled and drops the press — so the reason for the `Button` was gone before the branch merged. The action is that FAB in a `BottomAppBar`, the indicator in its icon slot while the key package is owed, and the reason line stays in the body above it |
is reached by the *event id* of a kind 0 the device already holds, and a pasted npub
has neither.
**`nostr:npub1…` is accepted and then dropped.** The field's check allows the prefix;
`bech32ToHexOrNull` on line 158 is handed the text with the prefix still on it,
fails, and the null goes unhandled. The dialog has already closed. An npub
with a bad checksum takes the same silent exit.
**The creation is committed before anything is confirmed.** When a key package is
there — a real person on Mantra, only not the one meant — `createMlsDirectMessage`
writes a room, an MLS group and a Welcome on the strength of a pasted string, and
the wrong person is invited to a conversation before the right one has been looked
at.
## What is already built
Almost all of it. The preview is a new screen over pieces that exist.
| piece | where | state |
|---|---|---|
| a person plus one action, reached by pubkey: `AddMemberToChatRoomConfirmationScreen` waits on a key package with a timeout and shows the profile's name over a bottom-bar button | [AddMemberToChatRoomConfirmationViewModel.kt:74](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt) | the shape, whole |
| the existing-room check, the profile-and-key-package wait, the timeout, the creation, the self-replacement | [ChatRoomMessagingViewModel.kt:69](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt) | stays exactly where it is; the preview hands over to it |
| the three-kind filter a chat needs — kind 0, key package, DM relay list — on the DM relays | `ChatRoomMessagingViewModel.scheduleProfileAndKeyPackageSync`, line 182 | private; lifted out in Phase 1 |
| a sync object with tests, and the rule that a placeholder is not a profile: `createdAt > GENESIS_AT` | [MemberProfileSync.kt:52](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt) | the precedent for both |
| where a kind 0 is found for a key that has lived elsewhere: the indexer relays plus our own | [SignInSync.kt:53](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt), `bootstrapRelays` | reused as is |
| the full npub validation — `nostr:` stripped, case folded, hrp checked, thirty-two bytes, a point on the curve | [CredentialParser.kt:139](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt) | private; exposed in Phase 3 |
| the queue stamps the asking identity on every request | [DatabaseNostrRepository.kt:686](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | nothing for a view model to do |
| a re-queued negentropy request inside the same minute re-arms the row rather than being ignored | [NegentropySynchronizeRequestDao.kt:32](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt) | "try again" works |
| a kind 0 arriving overwrites the placeholder row | [NostrDao.kt:424](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt) | the observed flow emits it |
| the stack shape for "a profile becomes a chat": push the room, pop the profile | [MantraNavHost.kt:1706](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), `NostrEventDetailRoute`'s DM exit | copied |
| a constant app bar with the state transition inside it | [GroupNostrProfileScreen.kt:106](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt) | the layout to copy |
The preview shows a profile only when a kind 0 has been read for it — the rule
`ChatRoomMessagingViewModel` already applies at line 136 and `MemberProfileSync` at
line 58, given a name in Phase 1 so that it is applied once.
Whom to ask is the sign-in plan's answer, for the sign-in plan's reason: an npub
pasted from elsewhere has lived elsewhere, and the indexer relays exist to hold
everyone's kind 0. So the kind 0 is asked of `SignInSync.bootstrapRelays` — the
indexers plus our own relay — and the key package and DM relay list, which only our
relay holds, are asked of the DM relays with the filter the chat screen already
uses. Two requests, not one, because a three-kind filter sent to an indexer returns
the kind 0 and nothing else, and a kind-0-only filter sent to our relay finds a
person who is not on Mantra nowhere.
Cached first. The observed flow emits the row the device already holds before any
relay answers, so a person already known — a room member, a follow, a search result
— is on screen at once, and the relays refresh them behind it.
### What the button does: hands over; it does not create
"Start new chat" pushes `ChatRoomMessagingRoute(chatRoomId = <pubkey>, relayHint = null)`
and pops the preview — exactly the route the dialog pushes today, and exactly the
exit `MetadataEventDetail`'s "Send message" takes. `initiateNewChat` then does what
it has always done: finds the existing room or creates one, and replaces itself with
it. Back from the room is the home screen, as it is from a chat opened off a profile.
Not creating on the preview keeps one creation path with one set of failure modes,
and keeps the preview a *reader*: nothing on it writes until the button is pressed,
and the button writes by leaving. It also means a read-only identity can be shown
the screen with the button hidden behind `LocalCanSign`, as `MetadataEventDetail`
hides its own, and the inventory in `ReadOnlyEntrancesJvmTest` gains a row rather
than an exception.
### Whether the button waits: yes, in its own phase
With Phases 1–3 alone the preview moves the twenty-second dead end one screen later:
the person is found on an indexer, the button is pressed, and `InitiatingNewChat`
waits for a key package that a person who has never opened Mantra will never have
published. Phase 4 answers that on the preview, where the answer can be read before
the button is pressed — a room that already exists opens; a key package that is
here enables the button; one that is not, after the relays have had their time,
disables it and says why. The two DM view models already wait on the key package
with the same timeout; Phase 4 waits on it one screen earlier, and the screen after
it never waits at all.
Its own phase because it changes what the button is allowed to do, and because it
adds a repository method whose rule — what counts as a direct message between two
keys — is today a loop with a `TODO` in it. That rule deserves its own commit and its
own test.
---
## Phase 1 — the lookup, and the sync it shares
**No screen.** A sync object, one rule named, and a view model — everything the
screen will read, testable without a composition.
### `DirectMessagePeerSync`
`ChatRoomMessagingViewModel.scheduleProfileAndKeyPackageSync` lifted into
`press.mantra.compose.nostr`, beside `MemberProfileSync` and shaped like it, with the
kind 0 request the preview adds:
```kotlin
/**
* Asking the relays for what a direct message with one person needs.
*
* Two asks, because two sets of relays hold two different things. The DM relays
* hold the key package and the DM relay list, and the kind 0 of anyone who has
* published one here; the indexer relays hold everyone's kind 0 and nothing else
* a chat needs. A three-kind filter to the indexers comes back with the kind 0
* alone; a kind-0-only filter to our relay finds a person who has never opened
* Mantra nowhere. See docs/npub-profile-preview.md.
*/
object DirectMessagePeerSync {
/** The purpose the chat screen has queued under since it was written; kept so nothing reading the queue by purpose changes. */
const val PURPOSE = "initiate-chat"
const val PROFILE_PURPOSE = "profile-preview"
/** What a chat needs. No `limit`: ignored on the negentropy path and, on the REQ fallback, `limit = 1` across three kinds returned the kind 0 alone. */
val KINDS = arrayOf(MetadataEvent.KIND, KeyPackageEvent.KIND, ChatMessageRelayListEvent.KIND)
/** One negentropy request per DM relay, level 0, the id bucketed by minute so a retry re-arms the row. */
fun requests(peerPublicKey: HexKey): List<NegentropySynchronizeRequest>
/** The kind 0 alone, as a REQ to `SignInSync.bootstrapRelays`, level 0. */
fun profileRequests(peerPublicKey: HexKey): List<SynchronizeNostrEventRequest>
}
```
`ChatRoomMessagingViewModel.scheduleProfileAndKeyPackageSync` becomes one line that
queues `DirectMessagePeerSync.requests(chatRoomId)`. Its comment about `limit` moves
onto `KINDS`, where the next reader will look for it. `AddMemberToChatRoomConfirmationViewModel.scheduleKeyPackageSync`
asks for the key package alone under a different purpose and is left as it is.
Level 0 on both, for the reason `MemberProfileSync` gives: it marks a request as one
somebody is waiting on, and a kind 0 arriving at level 0 over a placeholder queues the
rest of that person's profile kinds off the back of it
| `Loading` | the npub, in `bodySmall` and `onSurfaceVariant`, so the user can already check it is the one they pasted; under it `LoadingDataIndicator(fillScreen = false, text = looking_for_this_profile_on_the_relays)` |
| `Loaded` | the profile block and the button, below |
| `NotFound` | `EmptyState(message = no_profile_was_found_for_this_npub, icon = Icons.Default.PersonSearch, action = TextButton(try_again) → viewModel.retry())`. `EmptyState`, not `ErrorState`: nothing went wrong, the search came back empty, and that is the one absence this screen has to name |
| `Error` | `ErrorState(onRetry = viewModel::retry)` — the queue refused, and asking again is the right retry |
The profile block, in a `Column` under `readableContent()`, leading-aligned — rows
with an avatar align to a leading edge, and centring is for a block that is the only
thing on the screen, which this is not once the button is under it:
-`ProfileAvatar(size = 75.dp, profile, publicKey)` — 75dp is a dimension, not a
spacing, and is what `MetadataEventDetail` uses;
-`humanReadableNameOrPubkey()` in `titleLarge`;
-`staticIdentifier()` in `labelMedium`, when there is one — the nip05 or the
lightning address, which is the thing a user compares against what they were
told;
-`about` in `bodyMedium`, the whole of it: this is the one screen whose job is to
let the user read it;
- the npub, `hexToNpubHrp()` remembered once, in `bodySmall` and
`onSurfaceVariant`, wrapped — sixty-three characters of bech32 are what the user
pasted, and the block is not a confirmation without them.
The button, under the block, full width, only when `LocalCanSign.current`: