diff --git a/docs/README.md b/docs/README.md index fd5cf254..02bcfb50 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ silent, or a decision that looked arbitrary and was not. | [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles | | [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose | | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | +| [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes | | [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme | | [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand | | [curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md) | the second pull: the fork's several-profiles line, ten commits and its first schema migration — which of it is a fix Mantra has today, who allocates a schema version number when two trees share one history, and the one conflict, which was Mantra's own | @@ -64,3 +65,8 @@ identities, and its first phase changes one rule the other two share — a seed' key becomes a credential like any other — so read it with `StoredIdentity.merge`, `SovereignWalletViewModel.switchToIdentity` and the startup screen open, and read its table of where the build chose differently first. +The npub profile preview note is a phased plan that has not been built, and is the +smallest of the plans: it changes the entrance to the direct-message flow and +nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and +`ChatRoomMessagingViewModel.initiateNewChat` open, and its four decisions before +its four phases. diff --git a/docs/npub-profile-preview.md b/docs/npub-profile-preview.md new file mode 100644 index 00000000..2c8be310 --- /dev/null +++ b/docs/npub-profile-preview.md @@ -0,0 +1,788 @@ +# A profile before a chat + +How the "Direct message via npub" option comes to show the person before it starts +anything, why the preview is a screen reached by public key rather than a step inside +the dialog, and what the "Start new chat" button on it is allowed to do. + +Read this with `HomeScreen.kt`, `StartDirectMessageToNpubOrNip05Dialog.kt` and +`ChatRoomMessagingViewModel.kt` open. It changes the entrance to the direct-message +flow and nothing past it: the room is still created where it is created today, by +the code that creates it today. + +**Not built.** Four phases, one commit each, in the order given. Phases 1–3 are the +request as stated — a preview, and a button on it — and ship as a strict improvement +on today. Phase 4 is what makes the button honest, and it is last so that the first +three do not wait on it. + +## The flow today + +1. The "New chat" button on the home screen opens the sheet + ([HomeScreen.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt), + and again at 300 for the two-pane layout). +2. `NewChatBottomSheetDialog` — titled "Create new chat" — offers two cards. The + first, "Direct message via npub", hides the sheet and opens a dialog + ([HomeScreen.kt:353](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt)). +3. `StartDirectMessageToNpubOrNip05Dialog` is one field and a "Start chat" button. + The button is enabled when the text starts with `npub1` or `nostr:npub1`, or parses + as an email-like address + ([StartDirectMessageToNpubOrNip05Dialog.kt:125](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt)). +4. "Start chat" decodes the npub and pushes `ChatRoomMessagingRoute(chatRoomId = )` + (line 162). A nip05 goes to `ImplementationPendingRoute` (line 153). +5. `ChatRoomMessagingScreen` finds no room under that id and moves to + `InitiatingNewChat` + ([ChatRoomMessagingViewModel.kt:52](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt)), + and `initiateNewChat` (line 69) looks for a room that already has the two of you + in it, otherwise waits on the peer's profile *and* key package, with a twenty-second + timeout, then calls `createMlsDirectMessage` (line 153) and replaces itself with the + room it made. + +Three things are wrong with it, in the order a user meets them. + +**Nothing is confirmed.** The pasted string goes straight to creation. What the user +sees is "Creating new chat." and then a transcript with a name in the bar — or the +twenty seconds and "Couldn't find this profile's chat details on the relays yet" — +and in neither case a way to tell *wrong npub* from *right person, not on Mantra yet*. +The one place in the app that shows a person and offers "Send message", +`MetadataEventDetail` +([MetadataEventDetail.kt:296](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)), +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 | +| `observeProfileWithPublicKey`, `observeMarmotKeyPackageForPublicKey`, `getAllParticipantsWithPubKey`, `getChatRoomByIdentifier` | `NostrRepository`, `ChatRepository` | built | +| 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 | +| `ProfileAvatar`, `humanReadableNameOrPubkey()`, `staticIdentifier()`, `hexToNpubHrp()` | `Profile.kt`, `Credentials.kt` | built | +| 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 | +| `LoadingDataIndicator(text = …)`, `EmptyState(message, icon, action)`, `ErrorState(onRetry)`, `NavigateBackButton`, `LocalCanSign` | `widgets/` | built | +| strings: `profile`, `try_again`, `start_chat`, `send_message` | `strings.xml` | present; the new ones are listed per phase | + +## The four decisions + +### Where the preview lives: a pushed screen, by public key + +Three places were possible. Inside the dialog, under the field, as a card that +appears once the npub parses. On a screen that replaces the dialog, with the field at +its top. Or on a screen of its own, reached from the dialog by the pubkey it parsed. + +The third. The preview has real states — asked, found, not found after the relays +have had their time, and failed — and the conventions in `CLAUDE.md` put those on a +screen: `ScreenStateTransition` over a `when`, `EmptyState` with an action, +`ErrorState` with a retry, and a `NavigateBackButton` so the desktop has a way off +it. A `Dialog` has none of that, and one that spends twenty seconds searching relays +is a dialog doing a screen's job. Replacing the dialog with a screen is the better +product and is the one refactor this plan leaves for later, on purpose: it changes +the entrance, where the request was about what comes after it, and with the preview +keyed by pubkey the field can move onto the same screen without the screen changing +(see the [appendix](#the-field-on-the-preview-screen)). + +Keyed by *pubkey*, not by the kind 0's event id, because that is all the entrance +has. It is also why the same route will serve a scanned QR code, a `nostr:npub…` +link and, once one exists, a resolved nip05: every one of them arrives as a key. + +### What "found" means: a resolved kind 0, from the relays that hold everyone's + +A pubkey gets a `Profile` row the moment the device first sees it, and that row is +the "LOADING..." placeholder stamped `GENESIS_AT` +([NostrDao.kt:273](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +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 = , 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 + + /** The kind 0 alone, as a REQ to `SignInSync.bootstrapRelays`, level 0. */ + fun profileRequests(peerPublicKey: HexKey): List +} +``` + +`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 +([NostrDao.kt:1243](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +From an indexer that follow-up asks the indexer for key packages it does not hold, +which is harmless and is why the DM-relay request is queued explicitly rather than +relied on. + +### `Profile.isResolved()` + +```kotlin +/** Read off a kind 0, as against the "LOADING..." placeholder minted when a pubkey is first seen. */ +fun isResolved(): Boolean = createdAt > GENESIS_AT +``` + +A member function on `Profile`, beside `humanReadableNameOrPubkey()`. The two +call sites that spell the rule out — `ChatRoomMessagingViewModel` line 136 and +`MemberProfileSync` line 58 — switch to it in this commit, so that there is one +definition of resolved. The DAO's own `== GENESIS_AT` comparisons stay: they are +on the write side, deciding whether a row *is* the placeholder, and read better as +they are. + +### `ProfilePreviewViewModel` + +```kotlin +sealed interface ProfilePreviewUIState { + data object Loading : ProfilePreviewUIState + data class Loaded(val profile: Profile) : ProfilePreviewUIState + /** Every relay asked has had its time, and no kind 0 came. Not an error: a search that found nothing. */ + data object NotFound : ProfilePreviewUIState + /** The queue itself refused: nothing was asked. */ + data object Error : ProfilePreviewUIState +} + +class ProfilePreviewViewModel( + val activeUserPublicKey: HexKey, + val profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState, + val nostrRepository: NostrRepository, +) : ViewModel() { + var profilePreviewUIState by mutableStateOf(initialProfilePreviewUIState); private set + + fun initiate() { observeProfile(); askTheRelays() } + + /** Asks again. The observation is still live, so the answer arrives through the same collect. */ + fun retry() { profilePreviewUIState = ProfilePreviewUIState.Loading; askTheRelays() } +} +``` + +`observeProfile` collects `observeProfileWithPublicKey(profilePublicKey)`, maps a +row that is not resolved to null, `distinctUntilChanged`, and on a non-null profile +cancels the timeout and sets `Loaded`. A null keeps waiting; the timeout is what +says when to stop. + +`askTheRelays` cancels any running timeout, starts one — `RELAY_LOOKUP_TIMEOUT`, +twenty seconds, the value both DM view models use — that moves `Loading` to +`NotFound` when it fires, and queues `profileRequests` and `requests`. A throw from +either queue call sets `Error`. The collect is never cancelled by the timeout: a +kind 0 that arrives on the twenty-first second still loads, exactly as +`ChatRoomMessagingViewModel` keeps its collect live past its own. + +The timer rather than the queue's own "every relay has answered" signal, which the +sign-in screen reads through `LocalAccount`: that signal is joined to the account's +unsigned event, and a peer has none. The two DM screens use the timer; this is a +third, and the signal is a follow-up for all three (see out of scope). + +### Tests + +`DirectMessagePeerSyncTest` in `commonTest`, beside `MemberProfileSyncTest`: + +- `requests` yields one row per DM relay, purpose `initiate-chat`, level 0, the three + kinds and the one author, no limit, and an id equal to `computeId` of its relay and + filter; +- `profileRequests` yields one row per bootstrap relay, purpose `profile-preview`, + kind 0 alone, the one author, level 0, no `unsignedNostrEventId`; +- the two relay sets overlap on `ephemeral` and nowhere else — pinned so that a + change to either set is noticed here. + +`ProfilePreviewViewModelJvmTest`, with the recording fake `SignInToProfileViewModelJvmTest` +uses — `NostrRepository by NO_OP_NOSTR_REPOSITORY` with the profile flow a +`MutableStateFlow` and both queue calls appended to an `effects` list: + +- `initiate` queues both request lists, once each, and the state is `Loading`; +- a placeholder row (`createdAt = GENESIS_AT`) does not load; +- a resolved row loads, and loads *before* the queue is asked if it was already + there — cached first; +- with nothing arriving the state is `NotFound` after the timeout, and a resolved + row arriving after that still loads; +- `retry` from `NotFound` returns to `Loading` and queues both lists again; +- a queue call that throws sets `Error`, and `retry` from `Error` asks again. + +The view model launches on `Dispatchers.IO`, as the others do, so virtual time is +no help: the test does what `AcceptCuratedSuggestionViewModelJvmTest` does — +`Dispatchers.setMain(UnconfinedTestDispatcher())`, and `withTimeout(5_000)` around a +poll of the state — and the view model takes `RELAY_LOOKUP_TIMEOUT` as a +constructor parameter with the twenty-second default, so the timeout cases pass a +few hundred milliseconds instead. + +`MemberProfileSyncTest` and whatever covers `ChatRoomMessagingViewModel` stay green +through the `isResolved()` switch, which is the point of switching them in the same +commit. + +--- + +## Phase 2 — the screen and its route + +**Reachable by nothing.** The route is registered and the screen is complete, and +no button in the app navigates to it until Phase 3. That is deliberate: the screen +is reviewed as a screen, against its previews and its tests, before the flow that +depends on it changes. + +### The route + +```kotlin +@Serializable +data class ProfilePreviewRoute( + val activeUserPublicKey: String, + val profilePublicKey: HexKey, +): Route() +``` + +No `relayHint`. The dialog has none to give, and `ChatRoomMessagingRoute` takes +null for it today from the same dialog. + +### The screen + +`ProfilePreviewScreen`, in `ui/composable/`, laid out as `GroupNostrProfileScreen` +is: a constant `Scaffold` with a `TopAppBar` — title `profile`, `NavigateBackButton` +in the leading slot — and `ScreenStateTransition` over the view model's state +inside the content lambda. The `when` is not the composable's whole body, but none +of its branches needs `ColumnScope`, which is the one thing that layout costs. + +```kotlin +@Composable +fun ProfilePreviewScreen( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + onStartChat: (Route) -> Unit, + onNavigateBack: () -> Unit, +) +``` + +The four states: + +| state | what is drawn | +|---|---| +| `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`: + +```kotlin +Button(onClick = { + onStartChat(ChatRoomMessagingRoute(activeUserPublicKey, chatRoomId = profilePublicKey, relayHint = null)) +}) { + Icon(Icons.Default.Mail, contentDescription = Decorative) // the label is beside it + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.start_new_chat)) +} +``` + +A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite +confirmation uses, because Phase 4 will disable it with a reason beside it, and a +FAB has no disabled state in M3 or in the API. + +A read-only identity sees the block and no button. The screen is not reachable by +one today — the sheet is behind `canSign` — but the route will be reachable from a +link one day, and `MetadataEventDetail` already makes the same choice for the same +control. + +`initiate()` runs from a `LaunchedEffect` only when the screen arrives `Loading`, as +every screen that takes an initial state does; a test that passes `Loaded` never +touches the repository. + +### The words + +| name | text | +|---|---| +| `start_new_chat` | Start new chat | +| `looking_for_this_profile_on_the_relays` | Looking for this profile on the relays. | +| `no_profile_was_found_for_this_npub` | No profile was found for this npub on the relays it was asked of. Check the npub, or try again later. | + +Sentence case throughout. Apostrophes are written plainly — `StringCatalogueJvmTest` +is the record that `Don't` survives the round trip and `Don\'t` does not. + +### The host + +In `MantraNavHost`, after `NostrEventDetailRoute`: + +```kotlin +composable { backStackEntry -> + val route = backStackEntry.toRoute() + ProfilePreviewScreen( + activeUserPublicKey = route.activeUserPublicKey, + profilePublicKey = route.profilePublicKey, + nostrRepository = databaseNostrRepository, + // The room replaces the preview, as it replaces the profile detail: back + // from a chat is the list, not the person it was started from. + onStartChat = { chatRoom -> + navController.navigate(route = chatRoom) { popUpTo(route) { inclusive = true } } + }, + onNavigateBack = { navController.popBackStack() }, + ) +} +``` + +The stack after the button: `Home → ChatRoomMessaging(pubkey)`, and once +`initiateNewChat` has made the room, `Home → ChatRoomMessaging(roomId)` — the +same two steps the dialog produces today, with the preview gone from between them. + +### Tests + +`ProfilePreviewScreenJvmTest`, on the pattern of `GroupNostrProfileScreenJvmTest` +— `runDesktopComposeUiTest` at a phone's width, `MantraTheme`, `ProvideSnackbarHost`, +the state passed in, the NO_OP repository never asked: + +- `Loaded` draws the name, the nip05, the whole of the about, and the npub; +- "Start new chat" is there, and pressing it hands over exactly + `ChatRoomMessagingRoute(activeUserPublicKey, chatRoomId = pubkey, relayHint = null)`; +- with `LocalCanSign` false the block is drawn and the button is not — searched on + the unmerged tree, as `ReadOnlyEntrancesJvmTest` explains; +- `NotFound` shows its message and a "Try again"; +- `Loading` shows the npub above the indicator. + +`ReadOnlyEntrancesJvmTest` gains the row: the preview is a write entrance, and the +inventory is the list of those. + +A `@ConformancePreviews` preview of the `Loaded` state, as every screen has, so the +screenshot pass in `docs/material-design-conformance.md` covers it. + +--- + +## Phase 3 — the dialog hands over + +**The user-visible change.** After this commit "Direct message via npub" shows the +person before anything is started. + +### The parse + +`CredentialParser.npub` is exactly the validation the dialog needs and is private +to a parser for sign-in credentials, whose `parse` would also recognise a pasted +nsec — which a message-address field must never treat as an address. So the branch +is exposed on its own: + +```kotlin +/** + * The x-only key under an `npub1…`, with or without a `nostr:` prefix, or null. + * The one shape a message address can arrive in: hex stays a secret here for the + * reason [parse] gives, and an nsec is refused because it is not an address. + */ +fun npubOrNull(raw: String): HexKey? +``` + +`parse` calls it from its `npub1` branch, so the two cannot disagree. A pasted +`nprofile1…` returns null: the TLV form is a different shape, and reading its +pubkey and relay hints out is its own small change (out of scope, and the route +already has room for the hint). + +### The dialog + +`StartDirectMessageToNpubOrNip05Dialog` keeps its title, its field, its focus rule +and its nip05 branch. What changes: + +- the `inputTransformation` keeps what it parsed rather than whether it parsed: + `parsedPublicKey`, a `MutableState` set from `npubOrNull(text)`, replaces + the boolean `startChatButtonEnabled`, and the button is enabled when it or the + email-like parse is non-null. The `nostr:` bug and the bad-checksum bug were one + bug — the check and the decode were two different functions — and holding the + decoded key is the fix, because there is no second decode to disagree; +- the button reads `find_profile` with `Icons.Default.PersonSearch`, because it no + longer starts a chat and a label that says it does is the thing M3's "tell users + what will happen" is about; +- on confirm, the npub branch pushes + `ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = parsedPublicKey)` + through the same `onNavigateToRoute` — `HomeScreen` passes a push and needs no + change; the nip05 branch is untouched. + +The imports of `bech32ToHexOrNull` and `ChatRoomMessagingRoute` go with the old +branch. `NewChatBottomSheetDialog` is not touched. + +### The words + +| name | text | +|---|---| +| `find_profile` | Find profile | + +`start_chat` goes with its only reader, the button it labelled; `start_chat_via_npub_or_nip05`, the dialog's title, stays. + +### Tests + +`CredentialParserJvmTest` gains rows for `npubOrNull`: a bare npub, `nostr:npub1…`, +an upper-cased npub, a bad checksum, an `nsec1…`, an `nprofile1…`, sixty-four hex +characters — the last four all null. The npub the sign-in tests already use is the +fixture. + +A `StartDirectMessageToNpubOrNip05DialogJvmTest`: + +- typing a valid npub enables "Find profile", and confirming hands over + `ProfilePreviewRoute` with the decoded key; +- `nostr:npub1…` does the same — the test that would have failed on the old code; +- an nsec leaves the button disabled; +- an email-like address enables it and still lands on `ImplementationPendingRoute`, + pinned so that the nip05 branch's behaviour is recorded rather than assumed. + +The compose test drives the `TextField` through `performTextInput`; the +`inputTransformation` runs on every edit, so no other trigger is needed. + +--- + +## Phase 4 — ready before the button + +**The button becomes honest.** It opens a chat that exists, starts one that can be +started, and otherwise says why not, before it is pressed rather than twenty seconds +after. + +### The existing room + +`initiateNewChat`'s loop — every `Participant` row for the peer, its room, the +size-one-or-two rule, the `TODO` about groups of two — becomes a repository method +with the rule as its contract: + +```kotlin +/** + * The room that is a conversation between [userPublicKey] and [peerPublicKey] + * alone, if there is one: two participants and both of them, or one when the two + * keys are the same. A group of two is not told apart from a direct message here — + * the schema does not record which a room is — so this is the rule `initiateNewChat` + * has applied since it was written, in one place with its name on it. + */ +suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? +``` + +On `ChatRepository`, implemented in `DatabaseChatRepository` from +`getAllParticipantsWithPubKey` and `getChatRoomByIdentifier`, as the loop is today. +`initiateNewChat` calls it and navigates to what it returns; its own loop goes. +Whether a room the user has *left* (`leftGroupAt`) should count is a question the +loop never asked, and this phase does not answer it: same rule, one place. + +### Readiness + +`Loaded` grows a second field: + +```kotlin +data class Loaded(val profile: Profile, val readiness: Readiness) + +/** Whether pressing the button would get anywhere, read here rather than twenty seconds into the next screen. */ +sealed interface Readiness { + /** A conversation with this person exists; the button opens it. */ + data class ExistingChat(val chatRoomId: String, val relayHint: String?) : Readiness + /** Their key package is here; `initiateNewChat` will create the room without waiting. */ + data object CanStartChat : Readiness + /** Asked, not answered yet. */ + data object Checking : Readiness + /** The relays have had their time and no key package came. */ + data object NotYetOnMantra : Readiness +} +``` + +The view model takes `chatRepository` and, in `initiate`, reads +`findDirectMessageRoom` once before observing. Then it collects, as +`ChatRoomMessagingViewModel` does at line 130, the profile flow *combined with* +`observeMarmotKeyPackageForPublicKey`, and on each emission with a resolved profile +sets `Loaded(profile, readiness)` where readiness is `ExistingChat` if the room was +found, else `CanStartChat` if the key package is non-null, else `NotYetOnMantra` if +the timeout has fired, else `Checking`. The one timeout now does two things when it +fires: `Loading` becomes `NotFound`, and `Loaded(_, Checking)` becomes +`Loaded(_, NotYetOnMantra)`. A key package cancels it only if the profile has also +arrived; a profile cancels nothing on its own, since the key package is still owed. + +`retry()` from `NotYetOnMantra` keeps the profile on screen — it is found; it is the +key package being asked for again — sets readiness back to `Checking`, restarts the +timeout and queues `DirectMessagePeerSync.requests` alone. + +`ExistingChat` carries the room's `relayHint` from its `Participant` row for the +peer, which is what `ChatRoomMessagingRoute` wants and what the dialog never had. + +Readiness is a fact about a *loaded* profile, and a room does not make a person +known: a member still "LOADING..." — the case `MemberProfileSync` exists for — +waits on their kind 0 here like anyone else, and the open button appears with the +name. The alternative, a `Loaded` over a placeholder, would put "LOADING..." in +`titleLarge` on a screen whose one job is to show who this is. + +### The screen + +`ProfilePreviewScreen` and its host entry gain `chatRepository: ChatRepository`, +passed as `databaseChatRepository` like every screen that reads a room; the +factory hands it to the view model. The button reads its state: + +| readiness | button | under it | +|---|---|---| +| `ExistingChat` | `open_chat`, enabled → `ChatRoomMessagingRoute(chatRoomId = room id, relayHint)` | `you_already_have_a_chat_with_s` | +| `CanStartChat` | `start_new_chat`, enabled → as Phase 2 | nothing; the button is the answer | +| `Checking` | `start_new_chat`, disabled | a `LoadingIndicator` and `checking_whether_s_can_receive_messages_here` | +| `NotYetOnMantra` | `start_new_chat`, disabled | `s_hasn_t_set_up_messaging_on_mantra_yet`, and a `TextButton(try_again)` → `retry()` | + +`%1$s` is `humanReadableNameOrPubkey()` in every one. The line under the button is +`bodySmall` in `onSurfaceVariant` — a supporting line, not a second message. A +disabled button with the reason beside it, rather than a hidden one: the action +exists and is unavailable, which is what M3's disabled state is for, and a screen +that lost its only button would read as broken. + +Pressing "Open chat" pops the preview exactly as "Start new chat" does; the +`onStartChat` callback and its host wiring are unchanged. + +### The words + +| name | text | +|---|---| +| `open_chat` | Open chat | +| `you_already_have_a_chat_with_s` | You already have a chat with %1$s. | +| `checking_whether_s_can_receive_messages_here` | Checking whether %1$s can receive messages here. | +| `s_hasn_t_set_up_messaging_on_mantra_yet` | %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. | + +### Tests + +`DatabaseChatRepositoryJvmTest` — or a new `FindDirectMessageRoomJvmTest` beside +`SignInToProfileTwiceJvmTest`, on an in-memory Room under the app's own DAOs: + +- a room with exactly the two participants is found; +- a room with the peer and a third member is not; +- a room with the user alone is found when the peer is the user; +- no rows, null. + +`ProfilePreviewViewModelJvmTest` grows a `ChatRepository` fake beside the +`NostrRepository` one — `findDirectMessageRoom` answering a fixed room or null, the +key package flow a `MutableStateFlow`: + +- an existing room is `ExistingChat` the moment the profile loads, key package or + not — the case that would otherwise read "hasn't set up messaging" for someone + you talk to every day, since a key package is meant to be used once and the + relays need not hold a fresh one for a person you already have a room with; +- profile then key package: `Checking` then `CanStartChat`, and the timeout is + cancelled; +- key package then profile: `Loading` until the profile, then `CanStartChat` at once; +- profile and no key package: `Checking` until the timeout, then `NotYetOnMantra`, + and a late key package still flips it to `CanStartChat`; +- `retry` from `NotYetOnMantra` queues `requests` alone, not `profileRequests`, and + the profile stays on screen. + +`ProfilePreviewScreenJvmTest` grows the four rows of the table above: the label, +whether the button is enabled, the line under it, and for `ExistingChat` the route +handed over. + +`initiateNewChat` is covered wherever `ChatRoomMessagingViewModel` is; the +existing-room cases move to the repository test and stay green through the switch. + +--- + +## Checking the work + +Each phase, before its commit: + +```bash +./gradlew :composeApp:m3Audit +``` + +```bash +./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest +``` + +The audit's budgets this plan can touch: spacing literals (none — the avatar's +`75.dp` is a dimension), hardcoded colours (none), title case (every new string is +sentence case; `docs/scripts/m3-title-case.py --list` shows any that is not), +untriaged `contentDescription = null` (the button's icon is `Decorative`, the +avatar carries its own), and the bare-`clickable` budget, which nothing here uses. +The adaptive floor is untouched: the preview reads no breakpoint and needs none. + +Phase 3 is the one to run on a device or the desktop target as well as in tests, +because it is the one that changes what a tap does: the sheet, the dialog, "Find +profile", the preview, "Start new chat", the room, and back to the list. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | `DirectMessagePeerSync`, `isResolved()`, the view model, both tests | 0.5–1 | — | +| 2 | the route, the screen, the host, the strings, the screen test, the `@ConformancePreviews` entry | 1 | 1 | +| 3 | `npubOrNull`, the dialog, the two tests, a run on a device | 0.5 | 2 | +| 4 | `findDirectMessageRoom`, readiness, the four words, three tests | 1 | 3 | + +**About three days.** The uncertainty is in Phase 4, in one place: the combined +collect and the one timer that means two things. `ChatRoomMessagingViewModel` has +the same shape and got it wrong twice before it was right — the comments at lines +116 and 138 are the record — so the view model test's ordering cases (profile first, key +package first, neither, late) are the phase, and the screen is what is left. + +## Out of scope + +- **nip05.** The dialog's email-like branch still lands on `ImplementationPendingRoute`. + Resolving one is an HTTPS fetch of `/.well-known/nostr.json` and a lookup, and + the result is a pubkey — which is the preview's route. When it is built it ends + here: the dialog's nip05 branch resolves, then pushes the same route the npub + branch does. +- **The field on the preview screen.** See the appendix. One refactor, after Phase 3, + that removes the dialog rather than changing it. +- **`nprofile1…`**, whose TLV carries relay hints the route could use. A second + branch in `npubOrNull`'s caller, and the route's `relayHint` when it grows one. +- **The two-pane home screen.** From the preview, "Start new chat" pushes the room + full-screen on a wide window, as "Send message" on a profile does today; selecting + it in the detail pane instead means the home screen owning the preview, which is + a question for its list-detail layout (the adaptive phase of + [material-design-conformance.md](./material-design-conformance.md)) and not this + plan's. +- **Rooms the user has left.** `findDirectMessageRoom` applies the rule + `initiateNewChat` applied; whether a left room should open, or a fresh one be + made beside it, is a rule that needs deciding on its own. +- **A "chat with yourself" preview.** Pasting your own npub shows your own profile + and, through `findDirectMessageRoom`, opens the one-participant room if you have + one — exactly what `initiateNewChat` does today. Whether the button should say + something else for yourself is a product question this plan does not raise. +- **The queue's "every relay has answered" signal for a peer.** Three screens now + use a twenty-second timer where the sign-in screen reads a status. Giving the + queue a per-request handle that any screen can wait on is a change to the queue, + and would retire all three timers at once. +- **`ChatRoomMessagingScreen`'s `Error` state**, which after Phase 4 is reached + only by a key package that vanished between the preview and the press. It still + has no retry, as it has none today. + +## Appendix — what was considered and rejected + +### The preview inside the dialog + +A card under the field once the npub parses, with the button on the card. Fewest +taps. Rejected because the preview has states a dialog cannot carry well — twenty +seconds of searching, a not-found with a retry, a queue failure — and because a +`Dialog` on the desktop target is the surface this app has the least of and the +least reason to grow. The screen is where those states have a home, and the dialog +stays exactly as small as it is. + +### The field on the preview screen + +Replace the dialog with a screen: the field at the top, the preview filling in +under it as the npub parses, the button under that. The better product — the sheet's +other card already pushes a screen, and this would make the two cards symmetrical — +and the one this plan does not build, because it changes the entrance where the +request was about what follows it. With the preview keyed by pubkey it is a +follow-up that moves the field and deletes the dialog, and touches the screen only +to give it a state before `Loading`. + +### Reaching `MetadataEventDetail` instead + +Wait for the kind 0, then push `NostrEventDetailRoute` with its event id. It is the +app's profile screen and it has "Send message". Rejected because it needs the event +id, which the entrance does not have and would have to wait for on a screen of its +own — at which point that screen *is* the preview — and because it offers follow, +unfollow, add to group and block on the way to a chat, which is a profile, not a +confirmation. + +### Creating the chat from the preview + +Call `createMlsDirectMessage` on the button and push the room's id. It would spare +one `InitiatingNewChat` frame. Rejected because it would be a second creation path +with the same key-package wait, the same timeout and the same error, one screen +apart from the first; and because the preview is worth keeping a reader. The frame +it would spare is, after Phase 4, the one frame `InitiatingNewChat` shows when the +key package is already here. + +### A bottom sheet for the preview + +The sheet is already open when the flow starts. Rejected for the reason the +multiple-profiles plan gave for its own switcher: a sheet that pushes a screen from +under itself is a screen with a worse back story, and this one's whole purpose is +to push a screen.