From ba26c0b12adea0eabcae1fb9a47c605ecafc59ac Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 17:04:09 +0200 Subject: [PATCH 01/71] Update version name and gitignore --- .gitignore | 1 + composeApp/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8a62782e..e7ee90c3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ **/build/ xcuserdata !src/**/build/ +**/release/ local.properties .idea .DS_Store diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cde561e0..65f10bea 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -170,7 +170,7 @@ android { minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() versionCode = 2 - versionName = "0.1.1" + versionName = "0.1.2" } packaging { resources { From f4434ab15dc1b4175e39389c9432ba2c18933a12 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 23:06:23 +0200 Subject: [PATCH 02/71] build: advance lightning-kmp-app onto the nostr credentials file Moves the pin from 01489b8 to 84cc44c, three commits on the submodule's master: two features and the merge between them. The submodule's own messages call this the library half of the app's nsec/npub sign-in plan; the app half is not in this commit, which changes no composeApp source. The first feature gives a nostr key with no seed above it somewhere to live. A key pasted in as an nsec cannot go in seed.dat, whose reader runs MnemonicCode.toSeed over every entry and whose format is inherited from upstream, so it gets a sibling file, nostr-keys.dat, under the same KEY_NO_AUTH: one version byte, a 16-byte iv, and the ciphertext of a JSON map from x-only public key to private key. Every secret is re-derived on read and must land on its own key, so a corrupt file is refused rather than handed out under the wrong identity. The temp-file / read-back / atomicMove discipline SeedManager always had is lifted into AtomicFileWrite so the new file gets it by calling rather than by copying; SeedManager.writeSeedToDir now goes through it with its original check and its original exception type, and the three public entry points the app uses -- loadAndDecrypt, loadAndDecryptOrNull, writeSeedToDisk -- are unchanged. The second replaces that file's shape with a typed one before anything has written it in anger. nostr-credentials.dat holds one entry per x-only public key, each either {"type":"secret","privateKey":hex} or {"type":"public"}, so a profile signed in read-only and the same profile with its nsec pasted later are one entry in one file, and the second is a single write over the first. It is a new file rather than version 2 of the old one because an older build's identity listing returns on an unreadable version before it publishes anything, and would list no wallets at all, seed wallets included; a file it does not look for cannot do that to it. NostrCredentialManager.migrateFromNostrKeys reads the old file once, writes the new one and deletes the old, and runs only while the new file is absent. NostrKeyManager becomes LegacyNostrKeysFile, read-only, for that one caller. Also in WalletManager.kt: PrivateKey.nostrPublicKeyHex(), the x-only form, and LocalKeyManager.nostrPublicKey() now returns it. That function used to return publicKey().toHex(), the 33-byte compressed encoding, which is not a nostr public key at all -- nothing in the library called it, and the app has been carrying an extension of its own with the same name instead. Retiring that copy is the next commit, not this one. The nested experimental/lightning-kmp gitlink does not move: it stays at 5103b79, so the lightning-kmp -> bitcoin-kmp -> secp256k1-kmp chain under the composite is exactly what it was, and nothing native is rebuilt. Verified in the composite: :composeApp:compileDebugKotlinAndroid is clean, :composeApp:jvmTest is 733 tests, 0 failures, and :lightning-kmp-app:library:jvmTest is 159 tests, 0 failures, 3 skipped -- up from 128 by exactly the 31 tests in the four new classes (EncryptedNostrKeysTest, EncryptedNostrCredentialsTest, LegacyNostrKeysFileJvmTest, NostrCredentialManagerJvmTest), the skips still being the @Ignore'd ElectrumServersTest. Co-Authored-By: Claude Opus 5 --- lightning-kmp-app | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-kmp-app b/lightning-kmp-app index 01489b81..84cc44c8 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 01489b81fe8fa2e47ca813fe231b9e5cd3b34ee2 +Subproject commit 84cc44c855e3d173e26016b807c449153a3af597 From 39fb64b6c0fa0ff1e586158503a9210249016bcb Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 23:06:50 +0200 Subject: [PATCH 03/71] refactor: take nostrPublicKey() from the library, and pin it against NIP-06 The app has carried its own `LocalKeyManager.nostrPublicKey()` in WalletManagerExtension.kt since before the library's returned anything usable: the library's gave back `publicKey().toHex()`, the 33-byte compressed encoding, so the app went through quartz -- `KeyPair(privKey).pubKey.toHexKey()` -- to get the x-only key that relays, events and npubs actually carry. As of lightning-kmp-app 84cc44c the library's returns the x-only key too, and the app's copy is a second implementation of the one value that is every profile's identity. This deletes it and switches its three callers -- RelaysSocketManager, NavigationViewModel, CreateProfileViewModel -- to `fr.acinq.phoenix.managers.nostrPublicKey`. **The two were proved equal before anything was deleted, not read to be.** They go through different stacks (quartz's `KeyPair` against bitcoin-kmp's `xOnly()`), and a difference between them would not fail a build or a test: it would publish every existing profile under a new key on the next launch, with nothing on screen to say so. So the first form of the test in this commit built one `LocalKeyManager` from NIP-06's mnemonic exactly as CreateProfileViewModel does -- `NodeParamsManager.chain`, `NodeParamsManager.remoteSwapInXpub` -- and called both functions on it, the library's imported under an alias because the names collide. Equal on mainnet, which is what the app ships on, and equal on Testnet3, where the library derives from account 1' instead of 0'. Only then did the app's go. **What remains is a pin from the consuming side, anchored to NIP-06 rather than to a captured output.** A value copied out of a passing run pins the code to itself; NIP-06 publishes two test vectors -- a twelve-word and a twenty-four-word mnemonic, empty passphrase, path m/44'/1237'/0'/0/0 -- and that path is the library's mainnet path exactly, so on the chain the app ships on the answer is not ours to choose. Both vectors are asserted, and the test also asserts that `NodeParamsManager.chain` is Mainnet, so a chain change surfaces here as the moment the published answers stop applying rather than as two mysteriously failing assertions. **Quartz stays in the test, as an oracle rather than as an implementation.** The app signs events with quartz from `nostrPrivateKey()`, and the key it publishes has to be the one quartz derives for the secret it signs with; that agreement was the property the deleted copy embodied by construction. It is now stated once, on mainnet and on Testnet3 -- alongside the shape check, sixty-four lowercase hex characters, which is the line a revert to the compressed form (sixty-six) would trip. Off mainnet there is no published vector for account 1', so shape and quartz-agreement are all that is asserted there. **The test lives in `managers`, not `extensions`.** Its first form sat beside the function it was checking, in `press.mantra.compose.extensions`. With that function gone, a test in that package would be about an extension the app no longer has; `press.mantra.compose.managers` mirrors `fr.acinq.phoenix.managers`, the library package it pins, and is where the app's other key-material tests sit. The import in each caller is placed inside the file's sorted `fr.acinq.phoenix` block rather than where the old `press.mantra` line stood. :composeApp:compileDebugKotlinAndroid is clean; :composeApp:jvmTest is 736 tests, 0 failures -- the 733 before this change plus the three here. Co-Authored-By: Claude Opus 5 --- .../extensions/WalletManagerExtension.kt | 12 ---- .../network/relays/RelaysSocketManager.kt | 2 +- .../ui/view/model/CreateProfileViewModel.kt | 2 +- .../ui/view/model/NavigationViewModel.kt | 2 +- .../compose/managers/NostrPublicKeyJvmTest.kt | 72 +++++++++++++++++++ 5 files changed, 75 insertions(+), 15 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/WalletManagerExtension.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/NostrPublicKeyJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/WalletManagerExtension.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/WalletManagerExtension.kt deleted file mode 100644 index c6ab1442..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/WalletManagerExtension.kt +++ /dev/null @@ -1,12 +0,0 @@ -package press.mantra.compose.extensions - -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.phoenix.managers.nostrPrivateKey - -fun LocalKeyManager.nostrPublicKey(): String { - return KeyPair( - privKey = nostrPrivateKey().value.toByteArray() - ).pubKey.toHexKey() -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index d901402a..99ba4c70 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import fr.acinq.phoenix.data.ActiveWallet +import fr.acinq.phoenix.managers.nostrPublicKey import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -20,7 +21,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import press.mantra.compose.extensions.nostrPublicKey /** diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt index e1211ede..dce2283c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt @@ -17,6 +17,7 @@ import fr.acinq.bitcoin.byteVector import fr.acinq.lightning.Lightning import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPublicKey import fr.acinq.phoenix.managers.nsecPassword import fr.acinq.phoenix.utils.MnemonicLanguage import kotlinx.coroutines.CoroutineExceptionHandler @@ -24,7 +25,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import press.mantra.compose.extensions.nostrPublicKey import kotlin.time.Duration.Companion.seconds class CreateProfileViewModel( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt index 041f89dd..e3fa9011 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt @@ -9,6 +9,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStart import press.mantra.compose.ui.view.state.NavigationUIState import co.touchlab.kermit.Logger import fr.acinq.phoenix.data.ActiveWallet +import fr.acinq.phoenix.managers.nostrPublicKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -21,7 +22,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch -import press.mantra.compose.extensions.nostrPublicKey import kotlin.time.Duration.Companion.milliseconds class NavigationViewModel( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/NostrPublicKeyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/NostrPublicKeyJvmTest.kt new file mode 100644 index 00000000..6853fd5e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/NostrPublicKeyJvmTest.kt @@ -0,0 +1,72 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import fr.acinq.bitcoin.Chain +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `LocalKeyManager.nostrPublicKey()` is what the app publishes as a profile's + * identity, and since lightning-kmp-app `84cc44c` it comes from the library rather + * than from a copy of its own. This pins the contract from the consuming side: + * x-only, sixty-four lowercase hex characters, NIP-06's published answer for + * NIP-06's own mnemonics, and the same key quartz -- the stack the app signs with + * -- derives from that secret. A library change back to the 33-byte compressed + * form, or off the NIP-06 path, fails here before it renames every profile. + */ +class NostrPublicKeyJvmTest { + + private fun keyManager( + mnemonics: String, + chain: Chain = NodeParamsManager.chain, + swapInXpub: String = NodeParamsManager.remoteSwapInXpub, + ) = LocalKeyManager(MnemonicCode.toSeed(mnemonics, "").byteVector(), chain, swapInXpub) + + // NIP-06's two test vectors: empty passphrase, path m/44'/1237'/0'/0/0 -- which is + // the mainnet path, and the app ships on mainnet. + @Test + fun `is NIP-06's answer for its twelve-word vector`() { + assertEquals(Chain.Mainnet, NodeParamsManager.chain) + assertEquals( + "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917", + keyManager("leader monkey parrot ring guide accident before fence cannon height naive bean") + .nostrPublicKey() + ) + } + + @Test + fun `is NIP-06's answer for its twenty-four-word vector`() { + assertEquals( + "d41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573", + keyManager( + "what bleak badge arrange retreat wolf trade produce cricket blur garlic valid " + + "proud rude strong choose busy staff weather area salt hollow arm fade" + ).nostrPublicKey() + ) + } + + // Off mainnet the library derives from account 1', so there is no published answer; + // the shape and the agreement with quartz still have to hold there. + @Test + fun `is the x-only key quartz derives from the same secret, on every chain`() { + val mnemonics = "leader monkey parrot ring guide accident before fence cannon height naive bean" + val testnetSwapInXpub = "tpubDDt5vQap1awkyDXx1z1cP7QFKSZHDCCpbU8nSq9jy7X2grTjUVZDePexf6gc6AHtRRzkgfPW87K6EKUVV6t3Hu2hg7YkHkmMeLSfrP85x41" + for (km in listOf(keyManager(mnemonics), keyManager(mnemonics, Chain.Testnet3, testnetSwapInXpub))) { + val key = km.nostrPublicKey() + assertTrue(key.matches(Regex("[0-9a-f]{64}")), "${km.chain}: not 32 bytes of lowercase hex: $key") + assertEquals( + KeyPair(privKey = km.nostrPrivateKey().value.toByteArray()).pubKey.toHexKey(), + key, + "${km.chain}: quartz derives a different key from the same secret" + ) + } + } +} From b796a32be321c8189a3cd99ad20acf108a5669d9 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 11:04:35 +0200 Subject: [PATCH 04/71] docs: plan the pull of Curated's work back into Mantra, and the tool that makes it mechanical Curated (remote `curated`, curated/curated-kmp) forked from this repository at ba26c0b1 on 2026-09-09 and has landed thirty-nine commits since: a group's own nostr identity, curated lists over kinds 31888-31890, sign-in with a recovery phrase, an nsec or an npub, a back button on every pushed screen -- and two rebrands, 744 and 765 files each, underneath all of it. docs/curated-to-mantra.md is which of that Mantra wants, in what order, and how it lands under Mantra's names. docs/scripts/curated-unbrand.py is the rewrite the plan depends on, and docs/README.md gets the row. **The plan is measured, not argued, and that is its whole claim to be read.** The obvious way to write it was to reason from the commit list about what would conflict. Instead the method was run to the end on a scratch worktree before a sentence was written: Curated's history rewritten into Mantra's names, replayed onto origin/mantra with `rebase --rebase-merges`, built for both targets, and put through jvmTest, testDebugUnitTest and the m3 audit. Every number in the document is from that run -- 37 of 39 commits replay, 3 conflicts with known resolutions, 1,036 jvm tests and 530 common tests at 0 failures, all audit budgets met, Room regenerating nothing -- and the appendix records it so the next reader can tell a drifted number from a wrong method. A plan whose mechanics had not been tried would have been a list of hopes about seven hundred renamed files. **Mantra has not moved since the fork, and that decides the shape of the problem.** The merge-base of the two branches is origin/mantra itself, so `git merge curated/curated` is a fast-forward: it merges nothing and makes Mantra become Curare, icon, package rename and the deletion of Mantra's own library, dialects and projects sections included. The document says so before anything else because it is the one thing git does by default. It also means the pull is not a merge at all but a translation -- a Curated commit written in Mantra's vocabulary applies to Mantra as if it had been written there -- which is why a history rewrite followed by an ordinary rebase works, and cherry-picking the original commits (every path and every import line in every context wrong) does not. **808a3459, which drops the translation sections, is dropped rather than reverted afterwards, because that was measured too.** The first plan was to replay everything and restore the sections with a forward commit written against the final row layout. Trying the drop instead showed git merging 930d37c8, 1e52fc8f and 55664cc7's rewrite of ChatRoomDetailScreen around it without a conflict: the screen comes out as signing key, the five identity rows, Propose event, then Library, Dialects, Projects, then Subgroups, with `artifacts` and `dialects` still on the UI state, and the same 1,036 tests pass -- GroupSignedWorkRowsJvmTest only asserts that Subgroups is below the block. The one conflict is a modify/delete on a test 55664cc7 deletes anyway. A restore commit would have been Mantra owning a layout upstream never had. **Curated lists are pulled although they are not Mantra's product, and the reasoning is written out in full because it is the only real product question.** Excluding the five commits is not deleting five commits: the paste flow routes kind 31889 beside 0, 1 and 10002, broadcast seeds an entry's relays from its schema, the row restructure builds five rows of which two are these, the back-button sweep touches the suggestion screens, and 165 of the 306 strings added since the fork are theirs, interleaved. Carving that out means editing five other commits by hand and then owning a group screen that conflicts with upstream's on every future pull. Including it costs no schema, no migration and no query on a screen nobody opens. The principle underneath is stated as such: the method only stays cheap while Mantra's tree is a superset of Curated's non-brand tree, so every feature left out is a permanent seam. If product wants the two rows gone, that is two lines behind a constant, not surgery. **The brand line lands as one re-messaged commit of comments, and Torch is retired natively first.** Run through the rewrite, the two rebrand commits collapse from 744 and 765 files to sixteen and twenty-one, and what survives is the reasoning the rebrands added about the names they refused to change -- the comments on TWEAK_TAG, HOST_KEY_DERIVATION_TAG and Relays.ephemeral and the paragraph in shared-key-derivation.md. Mantra should have those; the next person to grep "mantra/" here is as likely to finish the rename as anyone upstream was. The rewritten d26cf6c7 is a different case: Mantra is still Torch in TorchTheme (116 references), UserAgent, two error strings, and an iOS config that builds Torch.app under a bundle id from two brands ago. That is Mantra's own debt and Phase 0 pays it as MantraTheme and "Mantra", so that CurareTheme maps onto a name that means something rather than onto the brand before last. Then d26cf6c7' is dropped in the replay, since its work is done -- the plan was made to say this explicitly after Phase 0 and Phase 2 were found to disagree about it. **The normaliser is a script in docs/scripts rather than a description, because three of its rules were wrong before they were right and prose would have hidden that.** `\b` does not fire inside snake_case, so `\bcurare\b` misses the string keys that carry the brand and they need look-around rules that treat `_` as a boundary. File names carry identifiers, so a path rule that only moves directories leaves MantraNavHost.kt and CurareNavHost.kt side by side with equal content. And "Curated" is two words -- the brand in generation one, the protocol's name at the tip (CuratedSchemaEvent, nostr/curated/, the queue's "Curated" mark) -- so the bare word is deliberately not a rule and only the generation-one identifiers are mapped, which is exact because the two commits written in that generation never use the word as a brand in code. Every rule matches a brand token and none matches a Mantra one, so the twelve Mantra* entities, the two mantra/ prefixes and ephemeral.mantra.press cannot be touched by construction; the check is that the entity-token count does not fall (433 before, 435 after, none lost) and that the rewritten tip compiles, which no missed identifier would survive. It has a tree mode for `filter-branch --tree-filter` and a patch mode for `format-patch` files; the consolidated script was re-run on a fresh export of the tip and reproduces the tree that was built and tested, to the byte, logo aside. **The phases are cuts through the graph, not lines of work, so no commit is replayed twice and the merges are recreated where they were.** The natural grouping is by feature line, but the lines interleave -- the nsec branch and the queue fork at the same commit and rejoin at c8de3a1f, the back-button branch joins the accept flow at fedbe724 -- and two of those merges carry hand resolutions that a flattened replay would silently lose. Each phase therefore replays every commit reachable from its cut that the previous phase did not, `--onto `, and the two conflicts and two reconcile files that the dry run found sit in Phase 4 with their resolutions written down as a runbook. The exactness check closes each phase: the diff between the replay and the rewritten tip must be exactly what was chosen to drop, and anything else is a rule that is wrong or a resolution that is, found there rather than in production. **Phase 6 says what the fork should become, because otherwise this document is needed again next week.** Every future upstream commit is written in to.curare.*, and each pull costs the dry run, the review and the prose -- affordable once, corrosive weekly. The options are given in the order they should be taken: converge the source package (a Kotlin package is not a brand, and the rebrands' own messages say the domain is Mantra* everywhere that matters), then invert the relationship so Curated is Mantra plus a thin brand-and-product series, and only failing both keep the normaliser maintained beside the code it maps. Two things already flow the other way and are named: 39fb64b6, since Curated still carries the dead WalletManagerExtension.kt and one caller, and Phase 0's Torch retirement. **Several counts in the draft were checked against the data and corrected before they were committed.** The entity-token figure quoted from the rebrand's message (1,369) was measured with a different pattern and is replaced by this run's own (433 -> 435); "roughly 150 of 293" strings became 165 of 306; the back-button commit touches eight of Mantra's translation screens, not nineteen; and the claim that nothing in the fork added a DAO method was too strong -- three queries were added, and the statement now says no table and no migration, with 19.json byte-identical after a build as the evidence. The dry run, for the record: normaliser at the tip 812 files rewritten and 806 paths moved; filter-branch over 39 commits in 84 s; replay with the logo and 808a3459 dropped 37 commits, 3 conflicts, 2 reconcile files, residual diff exactly the logo plus the three files 808a3459 touched; :composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean; :composeApp:jvmTest 1,036 tests, 0 failures (Mantra alone: 733); :composeApp:testDebugUnitTest 530 tests, 0 failures; :composeApp:m3Audit all budgets met with 12 adaptive uses and 2 navigation components. The scratch worktree and the tmp/ branches were removed afterwards; the script recreates them in under two minutes. Co-Authored-By: Claude Opus 5 --- docs/README.md | 4 + docs/curated-to-mantra.md | 621 ++++++++++++++++++++++++++++++++ docs/scripts/curated-unbrand.py | 164 +++++++++ 3 files changed, 789 insertions(+) create mode 100644 docs/curated-to-mantra.md create mode 100755 docs/scripts/curated-unbrand.py diff --git a/docs/README.md b/docs/README.md index 39176782..70f1efd3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ silent, or a decision that looked arbitrary and was not. | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | | [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 | | [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 | Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" @@ -35,3 +36,6 @@ once it has a key, and what it can say about a group that does not yet. The Material Design note is a phased plan that has not been built, and is the only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists. +The curated-to-mantra note is a phased plan that has not been built, though its dry +run has: it is about the repository rather than the app, and reads alone, except that +its first decision leans on the derivation note's one rule. diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md new file mode 100644 index 00000000..bcd69bd8 --- /dev/null +++ b/docs/curated-to-mantra.md @@ -0,0 +1,621 @@ +# Pulling Curated back into Mantra + +Curated forked from this repository on 2026-09-09 and has since landed thirty-nine +commits that Mantra has none of: a group's own nostr identity, curated lists over the +kinds 31888–31890, sign-in with a recovery phrase, an nsec or an npub, a back button on +every pushed screen — and two rebrands, 744 and 765 files each, underneath all of it. +This is which of that work Mantra wants, in what order, and how to land it under +Mantra's names without touching seven hundred files by hand. + +Read [nsec-sign-in.md](./nsec-sign-in.md) and [npub-sign-in.md](./npub-sign-in.md) for +the identity work's own reasoning once they arrive (Phases 3 and 5 bring them); nothing +here repeats it. [shared-key-derivation.md](./shared-key-derivation.md) states the one +rule this whole exercise is built around — that two strings in this tree are hash inputs +and must never be renamed — and the rebrands upstream were careful about it, which is the +only reason a mechanical pull is possible at all. + +**Not built.** Phase 1's dry run *has* been done, once, on 2026-09-13, on a scratch +worktree that was thrown away; every number below is measured on that run, not +estimated. Written against `origin/mantra` at `ba26c0b1` (with `f4434ab1` and +`39fb64b6` on the update branch) and `curated/curated` at `86cb876b`. Curated moves +daily: the numbers will drift, the method will not. + +## The shape of the fork + +Two remotes: `origin` is `mantra/mantra-kmp`, `curated` is `curated/curated-kmp`, both +on code.sigidli.com. The merge-base of `origin/mantra` and `curated/curated` is +`ba26c0b1` — which *is* `origin/mantra`. Mantra has not moved since the fork, so Curated +is strictly ahead of it, by thirty-nine commits. + +That has a consequence worth stating before anything else. **`git merge curated/curated` +into `mantra` is a fast-forward.** It would not merge anything; it would make Mantra +*become* Curare — the package rename, the icon, and the commit that deletes Mantra's own +library, dialects and projects sections included. It is the one thing not to do, and it +is the thing git does by default. + +The thirty-nine are thirty-five ordinary commits and four merges, and the branch +structure matters for the phasing later: + +``` +* 86cb876b Merge claude/npub-readonly-signin into curated +|\ +| * 3116eb8a … 78807fe9 npub sign-in: 12 commits (Phase 5) +* | 55664cc7 rows on the group's screen +* | fedbe724 Merge curated into the suggestion-accept branch (carries resolutions) +|\| +| * ccbdbf7a Merge curated into the back-button branch (carries resolutions) +| |\ +| * | 0634487e back button on every pushed screen +* | | 732a4527 accepted entries on the group's screen +* | | fcc19f95 accept a suggestion into the list +| |/ +|/| +* | 6ae5a967 broadcast button under each signed event +|/ +* c8de3a1f Merge curated into the nsec branch +|\ +| * 1e52fc8f the queue of a curated list +* | 2326839e … daae63d5 nsec sign-in: 8 commits (Phase 3) +|/ +* 392b90b8 an unsigned event, pasted +* 930d37c8 the lists a group curates (kind 31889) +* 808a3459 drop the library, dialects and projects sections <- Mantra drops this +* cd3108e9 Update logo <- and this +* e6aee792 rebrand curated -> curare <- and the brand line +* 334e6dd1 what the group says out loud (kind 1 posts) +* 4c0ed0c1 the group's own nostr identity (kind 0, relay lists) +* d26cf6c7 retire the brand before mantra (Torch) +* 3bc8be53 rebrand mantra -> curated +``` + +Two brand generations sit under the features. `3bc8be53` made the app Curated — +`press.mantra.*` became `com.it.curated.*`, `MantraDatabase` became `CuratedDatabase`, +and so on — and `e6aee792` made it Curare, `to.curare.*` and `CurareDatabase`. The two +group-identity commits are written in the first vocabulary; everything from `808a3459` +on is written in the second. A pull has to read both. + +What the rebrands did **not** touch is the reason a pull is tractable. Their own messages +record it: the twelve `Mantra*` Room entities keep their names because those names are +the SQLite table names; `SharedKeyDerivation.TWEAK_TAG` and +`ChillDkgRitualManager.HOST_KEY_DERIVATION_TAG` keep their `mantra/` prefixes because +they are hash inputs; `Relays.ephemeral` keeps `wss://ephemeral.mantra.press` because it +is a running relay. So on both sides the database is at version 19 with the same nineteen +schema files, every table has the same name, every derived key is the same key, and the +app talks to the same relay. **Nothing in the thirty-nine commits adds a table or a +migration** — three DAO queries and no entity field; after a full build of the pulled tree +Room regenerates nothing and `19.json` is byte-identical to Mantra's. Every feature reads +off `GroupSignedEvent` rows that already existed, or off two new files in the node-data +directory (`nostr-keys.dat`, then `nostr-credentials.dat`). The lightning-kmp-app submodule is pinned to the same commit +on both sides, `84cc44c`, since `f4434ab1` brought Mantra's pin forward. + +## The inventory + +Every commit, grouped by the line of work it belongs to. Sizes are `git show --shortstat` +sums; the brand line's is what a hand-merge would have had to read. + +| line | commits | size | verdict | +|---|---|---|---| +| A. the brand line | `3bc8be53` `d26cf6c7` `e6aee792` `cd3108e9` | 1,593 files, ±10.5k | **not as commits.** Their residue, yes — see [the first decision](#1-the-brand-line-and-what-is-worth-keeping-from-it) | +| B. the group's nostr identity | `4c0ed0c1` `334e6dd1` | 40 files, +5.3k | **pull** | +| C. drop the library, dialects, projects | `808a3459` | 4 files, −228 | **do not pull.** It deletes Mantra's product | +| D. curated lists | `930d37c8` `392b90b8` `1e52fc8f` `fcc19f95` `732a4527` | 86 files, +12.7k | **pull** — the [third decision](#3-curated-lists-are-not-mantras-product-and-mantra-should-take-them-anyway) | +| E. broadcast | `6ae5a967` | 18 files, +2.0k | **pull** | +| F. a back button on every pushed screen | `0634487e` | 50 files, +532 −288 | **pull** — a fix | +| G. nsec sign-in | `daae63d5` `42f3a697` `366b0177` `22061601` `d61d3560` `e3cc23ae` `647ee815` `2326839e` | 84 files, +5.3k | **pull** | +| H. a row per kind of signed work | `55664cc7` | 34 files, +4.1k −1.5k | **pull**, with C dropped underneath it | +| I. npub read-only sign-in | `78807fe9` `dc8a1091` `15766596` `5efeae76` `7db863e3` `bfad1f39` `e31033e8` `a586b7c1` `315e9331` `f866b9d1` | 67 files, +3.4k | **pull** | +| J. library chores | `00c36ec5` `3116eb8a` | 3 files | **pull** `3116eb8a`; `00c36ec5`'s pin move is already Mantra's | +| K. the four merges | `c8de3a1f` `ccbdbf7a` `fedbe724` `86cb876b` | — | replayed *as merges*; two carry hand resolutions | + +### B. The group's own nostr identity + +A Marmot room's id is the pubkey it signs as, so every group has had a nostr identity +since it had a key and no way to say anything about it. `4c0ed0c1` adds the kind 0 and +the four relay lists (NIP-65, NIP-17, NIP-50, NIP-51), each proposed to the quorum like +anything else the group signs; `334e6dd1` adds kind 1 posts in the group's own name. +Both are *readings* of `GroupSignedEvent` rows gated on `verifies` — the room as author, +the id as the hash, the signature real — and both close a hole in `applyInnerEvent` +where a group-signed kind would otherwise render as raw JSON in the transcript. The +second commit's kind 1 arm is the careful one: it verifies and, on failure, falls through +to `unsupported` rather than dropping the event, because a kind 1 is content somebody +meant. + +Mantra wants this without qualification. A translation collective is exactly a group +that needs a public name, a place to be found, and a way to announce a finished chapter +under a key no single member controls. Two commits, no schema, and the tests sign their +fixtures with real FROST quorums. + +### C. Dropping the library, dialects and projects + +`808a3459` removes the three work sections from the group screen and the two repository +reads that fed them. For Curated that was dead weight; for Mantra it is the product. The +question was never whether to take it but what taking the *later* commits costs when it +is left out, because `55664cc7` restructures the same screen on top of it. That was +measured — see [the second decision](#2-mantra-keeps-its-sections-and-git-does-the-work). + +### D. Curated lists + +The curated-list NIP from the bitcoin.mov repository: a group publishes a *schema* +(kind 31889) defining a list anyone may suggest entries to; strangers publish +*suggestions* (31888) against it; the group's quorum accepts one by signing a *canonical +entry* (31890) that points back at the suggestion. `930d37c8` is the schema and its +editor, ported tag for tag with the NIP's own example as the fixture; `392b90b8` is the +untyped way in — an unsigned event pasted as JSON and filed by kind once signed, which +exists so that `npm run schema:dry` in the bitcoin.mov repo can be pasted rather than +retyped; `1e52fc8f` is the queue, every suggestion in reply to a schema, read off the +relays the schema named; `fcc19f95` accepts one into the list, seeded into a form the +schema drives; `732a4527` shows what the group has accepted. All of it lives in a new +`nostr/curated/` package plus screens, and all of it reads the same `GroupSignedEvent` +rows. + +### E. Broadcast + +Until `6ae5a967` nothing a group signed reached a relay: `FrostSigningManager.complete` +filed the rows and the only way out was the copy button on the key-state sheet. This adds +a Broadcast button under every signed event, a screen that shows and edits the relays, +sends, and reports what each answered. It deliberately bypasses the durable +`BroadcastNostrEventRequest` queue, because that queue hangs off a `NostrEvent` row and a +group's event must not become one (the home feed would show it as anybody's, and the +sync loop would offer it to relays the screen never named). Mantra's groups sign +chapters, translations and key states; none of them reach a relay today either. + +### F. A back button on every pushed screen + +Twenty-two pushed screens had no back affordance. Android's system back and iOS's edge +swipe hid it; the desktop target, which Mantra also ships, has neither, so those screens +were dead ends there. One widget, `NavigateBackButton`, replaces four spellings of the +arrow across forty-one sites; four screens with no app bar got one. Eight of the fifty files +are Mantra's translation screens — `AddArtifactScreen`, `AddChapterScreen`, +`AddDialectScreen`, `ChapterDetailScreen`, `TranslationChapterScreen` and the rest — +which Curated kept and fixed. + +### G. nsec sign-in + +Phases 1–6 of `docs/nsec-sign-in.md`, which arrives with the commits. `42f3a697` puts an +`Identity` type in front of the wallet — the nostr key, the x-only pubkey, the +per-identity preferences and an *optional* `PhoenixBusiness` — replacing the expression +`activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()` at its ten +sites and `StateFlow` at its twenty-five. `366b0177` lists and starts +identities from both stores. `22061601` turns `SignInToProfileScreen` from the sentence +"Sign in is not yet available while Mantra is in alpha testing" into a screen: one field +that recognises a recovery phrase or an nsec, confirms the npub it will sign as, and +writes the secret before anything else. `d61d3560` fixes something not nsec-specific — the +sign-in sync asked only `ephemeral.mantra.press` for the user's profile, so a key that had +lived on Damus for three years found nothing; it now asks the indexer relays and follows +the user's own kind 10002 one hop. `e3cc23ae` gives an identity with no phrase a recovery +screen for its nsec and the app's first real "forget this key". + +The library half — `NostrKeyManager` when this line was written, `NostrCredentialManager` +by the end of line I — is in lightning-kmp-app `84cc44c`, which Mantra's pin already +carries; those library commits exist to serve these two lines. + +### H. A row per kind of signed work + +`55664cc7` collapses the five identity sections (profile, relays, posts, schemas, entries) +into five rows in the shape of the signing-key row, each opening its own screen, because +five sections of cards had made the group screen as long as everything the group had +ever said. Five routes, screens, view models and states; the broadcast button becomes a +widget. It sits on top of C, and how Mantra takes it without C is the second decision. + +### I. npub read-only sign-in + +Phases 1–6 of `docs/npub-sign-in.md`. `15766596` makes the identity's private key +nullable, with `canSign` as the one place to ask. `5efeae76` replaces `nostr-keys.dat` +with `nostr-credentials.dat` — one typed entry per public key, secret or public, so a +profile signed in read-only and the same profile with its nsec pasted later are one +entry, upgraded in one write — and brings the matching library commit, which is +`84cc44c`, Mantra's current pin. `7db863e3` lists and starts a keyless identity and +builds quartz's read-only `KeyPair` in exactly one place (the trap it guards is +`KeyPair(privKey = null)`, which is quartz's "make me a new key"). `bfad1f39` recognises +an npub in the sign-in field. `e31033e8` is the product half: a `LocalCanSign` +composition local, provided once above the navigation suite, hides every action that +would need a signature — new chat, follow, send message, key packages — rather than +disabling it, and the empty state beside each says why. `a586b7c1` is sign out, for a +read-only identity only, the first real one in the app. + +### J. Library chores + +`3116eb8a` adds `branch = master` to `.gitmodules` so `git submodule update --remote` +follows the library's default branch; take it. `00c36ec5` moved Curated's pin to +`84cc44c`; Mantra is already there. + +## Three decisions + +### 1. The brand line, and what is worth keeping from it + +None of the four commits lands as itself. But run through the name-rewrite described +below, the two rebrand commits collapse from 744 and 765 files to sixteen and +twenty-one, and what is left is not brand at all — it is the *reasoning the rebrands +added about the names they refused to change*: a comment on `TWEAK_TAG`, one on +`HOST_KEY_DERIVATION_TAG`, one on `Relays.ephemeral`, a paragraph in +`docs/shared-key-derivation.md`, and the relocation of a stale `press/auxiliary` +template test into the real package. Mantra should have those comments; the next person +to touch a `mantra/` string in Mantra is as likely to "finish the rename" as anyone in +Curated was. Take them as **one squashed commit with a new message** ("docs: say why the +two hash tags and the relay host must never be renamed") and rewrite the sentences that +describe rebrands Mantra never had — they are listed under +[Risks](#risks-and-the-silent-ones-in-particular). + +`d26cf6c7`, the Torch retirement, is a different case. Mantra is still Torch in four +places: `TorchTheme` (116 references), `UserAgent.APP_NAME` and `CLIENT_NAME` (the +`client` tag on every relay list this app publishes), two user-facing error strings in +Kotlin ("tell X to use Torch", "finished setting up Torch"), and iOS, where +`Config.xcconfig` still builds `PRODUCT_NAME=Torch` under the bundle id +`ac.aux.compose.Aux` — two brands ago. That is Mantra's own debt and Phase 0 pays it +natively, as `MantraTheme` and "Mantra", so that Curated's `CurareTheme` maps onto a name +that means something rather than onto the brand before last. + +The logo commit is Curated's icon. Mantra keeps its own. + +### 2. Mantra keeps its sections, and git does the work + +Dropping `808a3459` was measured rather than reasoned about. With it removed from the +replay, `930d37c8` and `1e52fc8f` merged into `ChatRoomDetailScreen` without conflict +(the schema section is inserted where the library section is removed, different hunks), +and `55664cc7`'s rewrite of the screen merged without conflict too. The screen that +results reads, top to bottom: signing key, then the five rows, then Propose event, a +divider, **Library, Dialects, Projects**, then Subgroups — `ChatRoomDetailUIState.Loaded` +still carries `artifacts` and `dialects`, and `mantraRepository` is still passed. It +compiles for both targets and passes all 1,036 jvm tests, including +`GroupSignedWorkRowsJvmTest`, which asserts only that Subgroups sits *below* the identity +block and does not mind three sections in between. + +The one conflict it produces is a modify/delete on +`GroupNostrProfileSectionJvmTest.kt`: `808a3459` had re-anchored that test from the +"Library" heading to "Subgroups", and `55664cc7` deletes the file in favour of +`GroupSignedWorkFixtures` and six new tests. Resolution: delete, as `55664cc7` does. + +A follow-up Mantra may want, and should write natively rather than pull: the three +sections in the same row shape as the five above them, so the screen is one idiom. That +is a small change against a settled layout, not a merge problem. + +### 3. Curated lists are not Mantra's product, and Mantra should take them anyway + +This is the only real product question in the pull, so the reasoning is set out in full. + +*Against:* kinds 31888–31890 are Curated's reason to exist. A translation collective has +no obvious list to curate, and two rows — "Curated schemas", "Curated entries" — appear +on every group screen. + +*For, on cost:* excluding the five commits is not deleting five commits. `392b90b8` +(paste) routes kind 31889 alongside 0, 1 and 10002; `6ae5a967` (broadcast) seeds an +entry's relays from its schema; `55664cc7` (rows) builds five rows of which two are +these; `0634487e` (back button) touches the suggestion screens; and 165 of the 306 strings +added since the fork are theirs, interleaved with the rest. Carving them out means editing five +other commits by hand and *then* owning a group-screen layout that differs from +Curated's, so that every future pull conflicts in the same place again. Including them +costs no schema, no migration, no query on a screen that does not open them, and a +self-contained package under `nostr/curated/` whose tests are the NIP's own vectors. + +*For, on principle:* the whole approach below rests on Mantra's tree being a superset of +Curated's non-brand tree, because that is what makes the *next* pull a replay rather than +a negotiation. Every feature left out is a permanent conflict seam. + +*So:* pull them. If product decides the two rows should not show on Mantra, hide the two +`SignedWorkRow` calls behind a constant — a two-line, reversible change against a tree +that is otherwise identical to upstream — rather than surgery on five commits. + +## How it lands: rewrite the names, then let git merge + +The principle is that **a Curated commit written in Mantra's vocabulary applies to Mantra +as if it had been written there**, because Mantra has not moved since the fork. So the +work is not merging; it is translating, once, mechanically, and then replaying history +with git's three-way merge doing what it is good at. + +Three approaches were considered. Merging `curated/curated` fast-forwards into Curare, +above. Cherry-picking the original commits and fixing up names is hopeless: two 700-file +renames mean every patch's paths and every `import` line in its context are wrong, so +every file of every commit conflicts. The third — rewrite the *history* into Mantra's +names and replay it — is what was measured, and it works. + +### The normaliser + +[`docs/scripts/curated-unbrand.py`](./scripts/curated-unbrand.py) maps both brand +generations onto Mantra, in a tree (for `filter-branch`) or in a patch file. Its rules +are a table of brand tokens: `to.curare.` and `com.it.curated.` to `press.mantra.`; the +generated-resources package; the app-level identifiers (`CurareDatabase` and +`CuratedDatabase` to `MantraDatabase`, the nav host, the navigation suite, the global, +the application, the converters, the shapes, the desktop dir); the theme; the brand +string key and its value; `UserAgent`; `APP_DIR_NAME`; `rootProject.name`; the Android +`applicationId` and `namespace`; the iOS product name and bundle id; and finally the bare +words `Curare` and `curare` in prose. + +Three things it got wrong on the way to being right, each of which will bite anyone who +rewrites it: + +- **`\b` does not fire inside snake_case.** `_` is a word character, so + `\bcurare\b` misses `sign_in_is_not_yet_available_while_curare_is` and + `curare_will_delete_the_nsec_for_s_from_this`. String keys carry the brand, and need + look-around rules that treat `_` as a boundary. +- **File names carry identifiers too.** `CurareNavHost.kt` must become `MantraNavHost.kt`, + not just its contents; a path rule that only moves directories leaves + `MantraNavHost.kt` and `CurareNavHost.kt` side by side with identical content. +- **"Curated" is two words.** In generation 1 it was the brand; at the tip it is the + protocol's name — `CuratedSchemaEvent` (158 uses), `CuratedEntryEvent`, the + `nostr/curated/` package, and a `curated` string that marks a queue row the group has + taken up. The bare word is therefore *not* a rule. Only the generation-1 *identifiers* + are mapped, and that is exact because the two commits written in that generation never + use `Res.string.curated` or the bare word as a brand in code. + +What it cannot touch, by construction: every rule matches a brand token and none matches +a Mantra one, so the twelve `Mantra*` entities, the two `mantra/` prefixes and +`ephemeral.mantra.press` pass through untouched. The check is that the count of +`Mantra*` entity tokens does not go down across the rewrite — 433 in Mantra, 435 in the +dry run's tree, two new references and none lost — plus the fact that the rewritten tip +compiles, which no missed identifier would survive. + +### The pipeline + +```bash +git fetch curated +git branch tmp/curated-src curated/curated +git filter-branch -f -d /tmp/fb --tree-filter 'python3 /abs/path/to/docs/scripts/curated-unbrand.py tree .' \ + -- origin/mantra..tmp/curated-src # 84 s for 39 commits +``` + +Now `tmp/curated-src` is Curated's history in Mantra's names. Its first commit, the +rebrand, is sixteen files of comments; its features are Mantra commits that never +happened. Replay them, recreating the merges and dropping the two commits Mantra does not +want: + +```bash +git branch curated-pull tmp/curated-src # the branch that gets rewritten; tmp/curated-src stays as the reference +LOGO=$(git log --format=%h origin/mantra..tmp/curated-src --grep='Update logo' | tail -1) +SECTIONS=$(git log --format=%h origin/mantra..tmp/curated-src --grep='drop the library, dialects' | tail -1) +GIT_SEQUENCE_EDITOR="sed -i -e '/^pick ${LOGO:0:7}/d' -e '/^pick ${SECTIONS:0:7}/d'" \ + git rebase -i --rebase-merges --onto origin/mantra origin/mantra curated-pull +``` + +`--rebase-merges` matters: the two merges that carried hand resolutions are re-performed +rather than flattened, so their resolutions are re-asked at the same points instead of +silently lost. + +### What the dry run found + +| | drop the logo only | drop the logo and `808a3459` (**recommended**) | +|---|---|---| +| commits replayed | 38 | 37 | +| commits that conflicted | 2 | 3 | +| where | `strings.xml` at the recreated `c8de3a1f`; `CuratedSuggestionListScreen.kt` at the recreated `fedbe724` | the same two, plus `GroupNostrProfileSectionJvmTest.kt` at `55664cc7` (modify/delete) | +| files the original merges edited outside their conflicts | 2 | 2 | +| `git diff tmp/curated-src HEAD` afterwards | exactly the logo's 13 files | the logo, plus the three files `808a3459` touched — the screen, its view model, its UI state | +| `compileDebugKotlinAndroid`, `compileKotlinJvm` | clean | clean | +| `:composeApp:jvmTest` | 1,036 tests, 0 failures | 1,036 tests, 0 failures | +| `:composeApp:testDebugUnitTest` | 530 tests, 0 failures | 530 tests, 0 failures | +| `:composeApp:m3Audit` | all budgets met | all budgets met | + +Mantra's own suite is 733 jvm tests (736 with `39fb64b6`); the other three hundred came +with the features. + +The three conflicts and their resolutions: + +| at | file | why | resolution | +|---|---|---|---| +| `c8de3a1f'` | `strings.xml` | two branches appended strings at the end of the file | keep both blocks | +| `fedbe724'` | `CuratedSuggestionListScreen.kt` | the back-button sweep and the accept flow both edited the sheet | take the original merge's file: `git show :` | +| `55664cc7'` | `GroupNostrProfileSectionJvmTest.kt` | `808a3459` (dropped) modified it; `55664cc7` deletes it | delete | + +And the two files whose changes lived only *inside* the original merge commits — edits a +merge made beyond resolving its conflicts, which a recreated merge that does not conflict +will never reproduce: + +```bash +git checkout tmp/curated-src -- \ + composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt \ + composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +git commit -m 'reconcile: the two files the original merges edited outside their conflicts' +``` + +Then the check that makes the whole thing honest: + +```bash +git diff --stat tmp/curated-src HEAD # must be exactly: the logo's 13 files + the three files 808a3459 touched +``` + +Anything else in that diff is a normaliser rule that is wrong or a resolution that is, +and it is found here rather than in production. + +## The phases + +Each phase ends the same way: `:composeApp:compileDebugKotlinAndroid`, +`:composeApp:jvmTest`, `:composeApp:testDebugUnitTest` and `:composeApp:m3Audit` all +green on the branch, and one review of the diff against the phase's stated contents. +The phases are cuts through the graph above — each one replays every commit reachable +from its cut that the previous phase did not — so the ranges are `--onto + ` and no commit is replayed twice. + +### Phase 0 — Mantra prepares, natively + +Nothing from Curated lands. Three things Mantra does to itself so the pull is clean: + +1. **Merge the update branch** (`f4434ab1`, `39fb64b6`): the library pin at `84cc44c` + that the identity work needs, and the removal of the app's duplicate + `nostrPublicKey()`. +2. **Retire Torch.** `TorchTheme` → `MantraTheme`; `UserAgent.APP_NAME` and + `CLIENT_NAME` → `"Mantra"`; the two error strings; `Config.xcconfig` to + `PRODUCT_NAME=Mantra` and `press.mantra.ios`, with the pbxproj's product references. + Keep the three Torch *records* the rebrands kept: + `material-design-conformance.md:278` and `:654`, and `NavigationRoutingTest`'s + fixture. Then change the normaliser's theme and `UserAgent` rules — the ones marked in + the script — so `CurareTheme`/`CuratedTheme` map to `MantraTheme` and the agent to + `"Mantra"`. +3. **Take the protective comments** — or leave this for Phase 2, where the rewritten + rebrand commits carry them. Either way the commit is squashed and re-messaged, the + sentences under [Risks](#risks-and-the-silent-ones-in-particular) are rewritten by + hand, and the rewritten `d26cf6c7` is dropped in Phase 2 because item 2 has done its + work. + +Exit: 736 jvm tests, audit green, iOS config no longer says Torch. + +### Phase 1 — The dry run + +The pipeline above, end to end, on a scratch worktree, landing nothing. Its output is the +table in the previous section, recomputed against the day's `curated/curated`. If a +number moved, find out why before Phase 2: a new conflict means a new upstream commit +touched a seam; a non-empty exactness diff means a new brand token the normaliser does +not know. Delete the worktree and the `tmp/` branches when done; the script recreates +them in under two minutes. + +### Phase 2 — The group's identity, and the lists' read side + +Cut at `392b90b8'`: `4c0ed0c1`, `334e6dd1`, `930d37c8`, `392b90b8`, with `808a3459` +dropped. The three rewritten brand commits are ancestors of this cut too: if Phase 0 took +the protective comments and retired Torch natively, **drop all three** — their whole +content is already on Mantra and replaying them re-applies it; if Phase 0 skipped item 3, +squash `3bc8be53'` and `e6aee792'` into the one re-messaged comments commit and drop +`d26cf6c7'`. Four feature commits either way, no conflicts in the dry run. + +What to look at in review: the `applyInnerEvent` arms (the kind 1 arm must fall through +to `unsupported`, not return null); that `GROUP_IDENTITY_TYPES` renders as a +`RitualNotice` and not a bubble; that `CuratedSchemaEventTest` still parses the NIP's +example verbatim under Mantra's package. + +Exit: group screen shows profile, relays, posts and schemas as sections above Library; +tests green. + +### Phase 3 — Sign-in with a recovery phrase or an nsec + +Cut at `2326839e'`: the eight commits of line G, including the plan and its record. The +`Identity` refactor touches eighteen files' signatures; the dry run applied it without +conflict, but on top of Phase 0's `39fb64b6` expect two trivial ones — `42f3a697` removes +the `press.mantra.compose.extensions.nostrPublicKey` import from `RelaysSocketManager` +and `NavigationViewModel`, and `39fb64b6` had already replaced that line with the +library's import. Resolution: delete the line; the call it served is gone too. + +What to look at: `SignInToProfileViewModel.commit` writes the secret before it does +anything else; `SignInSync`'s bootstrap set is the indexer relays plus +`ephemeral.mantra.press`, not `ephemeral` alone; `NsecRestoreRoundTripJvmTest` asserts no +node ran. + +Exit: a pasted nsec signs in on desktop and Android; `NostrSecretScreen` reveals and +forgets it. + +### Phase 4 — The queue, broadcast, accepting, the back button, the rows + +Cut at `55664cc7'`: `1e52fc8f`, the recreated `c8de3a1f`, `6ae5a967`, `fcc19f95`, +`732a4527`, `0634487e`, the recreated `ccbdbf7a` and `fedbe724`, `55664cc7`. All three +conflicts and both reconcile files live here; the table above is the runbook. + +What to look at: the group screen's order (signing key, five rows, Propose event, +Library, Dialects, Projects, Subgroups); every screen reached by `navigate(...)` has a +back button on desktop; `BroadcastGroupSignedEventScreen` sends through +`EventPublishTransport` and not the broadcast queue; `defaultRelaysFor` an entry is its +schema's relays. + +Exit: the exactness diff is the logo plus the three files `808a3459` touched; audit +budgets met, as in the dry run. + +### Phase 5 — Read-only identities + +Cut at `86cb876b'`: the ten commits of line I, `3116eb8a`, and the final merge. No +conflicts in the dry run. `00c36ec5`'s pin change is a no-op against Mantra's pin and +can be dropped or left; dropped is cleaner. + +What to look at: `Identity.toKeyPair` is the only place a quartz `KeyPair` is built from +an identity; `ProvideSigningCapability` sits once above the navigation suite; the +migration from `nostr-keys.dat` runs before the first listing and leaves an unreadable +old file in place with an error rather than silently losing identities. + +Exit: an npub signs in read-only, every signing action is hidden with a reason beside +it, sign out removes it and re-lists. + +### Phase 6 — Close the loop + +Two things flow the other way, and one decision closes the reason this document exists. + +**Reverse-pull `39fb64b6`.** Curated still carries the app-side `WalletManagerExtension.kt` +and one caller (`CreateProfileViewModel`), while its own identity code already uses the +library's `nostrPublicKeyHex()`. The same normaliser runs backwards with its rules +inverted, or — for one small commit — it is a hand cherry-pick. + +**Reverse-pull Phase 0's Torch retirement**, so both trees agree that `CurareTheme` and +`MantraTheme` are the same thing under two names. That, too, is one commit. + +**Then decide what the fork is.** Every future Curated commit will be written in +`to.curare.*`, and every pull will need this pipeline again. With the tooling in place +the machine work is minutes and the conflicts are the three above; the cost is the dry +run, the review and the prose — call it half a day per pull, an estimate. That is +affordable once and corrosive weekly. The options, in the order they should be taken: + +1. *Converge the package name.* A Kotlin package is not a brand — the rebrands' own + messages say the app's domain is still `Mantra*` everywhere that matters — and if + Curated moved its source back to `press.mantra.*` while keeping `to.curare` as its + `applicationId`, `Curare` as its `rootProject.name` and `CurareTheme` as its theme, + the diff between the two trees would shrink to a handful of files and every pull in + either direction would be a plain cherry-pick. It costs Curated one more rename, its + fourth, and this time the last. +2. *Invert the relationship.* With the packages converged, Mantra is the natural + upstream: Curated becomes Mantra plus a short brand-and-product patch series (the + applicationId, the theme colours, the icon, the section removal, `APP_DIR_NAME`) + rebased on top of Mantra's main. Features are written once, on Mantra, and Curated + takes them by rebasing. This is what the current fork already *is* in content; it is + only the history that says otherwise. +3. *Keep pulling.* If neither is wanted, keep `curated-unbrand.py` maintained beside + the code it maps, redo Phase 1 before every pull, and treat a non-empty exactness diff + as a bug in the script. + +## Risks, and the silent ones in particular + +**The five names.** `Mantra*` entities, the two `mantra/` prefixes, the relay host, and +— on Mantra's side — nothing more. The normaliser cannot touch them; a hand edit can. The +check is `git grep -c 'mantra/' composeApp/src` and the entity token count before and +after, and `:composeApp:jvmTest`, which is what catches a renamed table. + +**Prose that lies after a rename.** A blanket rewrite turns "survived two rebrands — +Mantra to Curated, Curated to Curare" into a sentence about rebrands Mantra never had. +Eight places, ten lines, all in the rewritten brand commits or the docs, all needing a +human sentence: +`Relays.kt:9`, `SharedKeyDerivation.kt:65`, `ChillDkgRitualManager.kt:95`, +`PlatformContext.jvm.kt:48–56` (a whole block about `APP_DIR_NAME` keeping the old +brand, which is false for Mantra and should go), `HomeScreen.kt:167`, +`docs/shared-key-derivation.md:68–69`, `docs/material-design-conformance.md:656–657`, +and the `strings.xml` header comment. Rewrite them in Phase 0 or Phase 2's squash, not +later. + +**`strings.xml` grows from 377 to 670 strings** and conflicts by appending at every +branch join. Keep both blocks. Do not rename the `curated` string: it is the queue's +"Curated" mark, product vocabulary, and the normaliser leaves it alone on purpose. + +**Merges that did more than resolve.** Two files; the reconcile step; the exactness diff +catches any third. + +**Test anchoring.** `GroupSignedWorkRowsJvmTest` asserts Subgroups is below the identity +block. It is, with three sections between. `GroupNostrProfileSectionJvmTest` is deleted +upstream and stays deleted. + +**iOS is unverified on every side of this.** The rebrands say so, the dry run ran on +linux, and Phase 0's `Config.xcconfig` change is the first time this repo will have named +itself there. Build it on a Mac before believing it. + +**Data on disk moves nowhere.** Desktop `APP_DIR_NAME` stays `mantra`; the new +`nostr-credentials.dat` sits beside `seed.dat` in the node-data directory; the migration +from `nostr-keys.dat` is idempotent and refuses to delete what it could not read. An +existing Mantra install sees new files and no moved ones. + +**The upstream moves.** Between this repository's previous fetch of `curated/curated` +(`cd3108e9`, 2026-09-10) and the one this plan was written against (`86cb876b`, +2026-09-12) it gained thirty-three commits. Redo Phase 1 the day you land; the numbers +drift, the method does not. + +## Appendix: the dry run, for the record + +Run on 2026-09-13 against `curated/curated` at `86cb876b`, on a scratch worktree of +`origin/mantra` at `ba26c0b1`, with `lightning-kmp-app` at `84cc44c` symlinked in from a +populated checkout. + +| measurement | value | +|---|---| +| normaliser at the tip | 812 files rewritten, 806 paths moved | +| rewritten `3bc8be53` (rebrand 1) | 16 files, +61 −38 | +| rewritten `d26cf6c7` (Torch retirement) | 5 files, +16 −10 | +| rewritten `e6aee792` (rebrand 2) | 21 files, +85 −63 | +| rebrand 2 after normalising both sides | only sentences that describe the rebrand itself | +| `filter-branch` over 39 commits | 84 s | +| replay, logo dropped | 38 commits, 2 conflicts, 2 reconcile files, exactness = logo | +| replay, logo and `808a3459` dropped | 37 commits, 3 conflicts, 2 reconcile files, exactness = logo + the 3 files `808a3459` touched | +| `compileDebugKotlinAndroid` + `compileKotlinJvm` | clean, both variants | +| `jvmTest` | 1,036 / 0 failures, both variants (Mantra alone: 733) | +| `testDebugUnitTest` | 530 / 0 failures | +| `m3Audit` | all budgets met; 12 adaptive uses, 2 navigation components | +| Room schemas found at `press.mantra.compose.database.MantraDatabase/` | 19 | +| `Mantra*` entity tokens (`MantraArtifact|Chapter|Chunk|Dialect|Translation*|Dao|Repository`), before and after | 433 → 435; none lost | diff --git a/docs/scripts/curated-unbrand.py b/docs/scripts/curated-unbrand.py new file mode 100755 index 00000000..0b01fdd3 --- /dev/null +++ b/docs/scripts/curated-unbrand.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Rewrite a tree, or a patch, from Curated's names into Mantra's. + +Curated (curated/curated-kmp, brand "Curare" since e6aee792, "Curated" before it) forked from +Mantra at ba26c0b1 and renamed the app twice: `press.mantra.*` -> `com.it.curated.*` -> +`to.curare.*`, with the app-level identifiers (`MantraDatabase`, `MantraNavHost`, ...) and the +generated-resources package following. Every feature commit on the fork is therefore written in +one of those two vocabularies. This script maps both back to Mantra's, so a Curated commit can be +replayed onto Mantra as an ordinary cherry-pick. docs/curated-to-mantra.md is the plan it serves. + +What it must never touch, and by construction cannot: every rule below matches a *brand* token +(`Curare`, `curare`, `com.it.curated`, a `Curated*` app identifier) and none matches a Mantra one. +The twelve `Mantra*` Room entities, the two `mantra/` hash-tag prefixes, `wss://ephemeral.mantra.press` +and the `curated` product vocabulary (`CuratedSchemaEvent`, `nostr/curated/`, the "Curated" queue +mark) pass through unchanged. The bare word "Curated" is deliberately NOT a rule: in generation 1 +it was the brand, at the tip it is the protocol's name, and the two generation-1 feature commits +never use it as a brand in code -- so mapping only the gen-1 *identifiers* is exact. + +Modes + tree rewrite a checked-out tree in place: contents, then paths (for filter-branch) + patch ... rewrite git format-patch files in place: headers, paths and body alike + +Check your work the way the plan does: the rewritten tip must compile and pass jvmTest, and the +diff between a replay and the rewritten tip must be exactly the commits you chose to drop. +""" +import os, re, shutil, sys + +# ---- content rules, applied in order. \b keeps `CurareApp` off `CurareApplication`. ---- +_RULES = [ + # generation 2: to.curare / Curare* + (r'to\.curare\.', 'press.mantra.'), + (r'to/curare/', 'press/mantra/'), + (r'curare\.composeapp\.generated\.resources', 'mantra.composeapp.generated.resources'), + (r'\bCurareDatabaseConstructor\b', 'MantraDatabaseConstructor'), + (r'\bCurareDatabaseJvmTest\b', 'MantraDatabaseJvmTest'), + (r'\bCurareDatabase\b', 'MantraDatabase'), + (r'\bCurareApplication\b', 'MantraApplication'), + (r'\bCurareApp\b', 'MantraApp'), + (r'\bCurareGlobal\b', 'MantraGlobal'), + (r'\bcurareGlobal\b', 'mantraGlobal'), + (r'\bCurareNavHost\b', 'MantraNavHost'), + (r'\bCurareNavigationSuite\b', 'MantraNavigationSuite'), + (r'\bCurareShapes\b', 'MantraShapes'), + (r'\bCurareConverters\b', 'MantraConverters'), + (r'\bdefaultCurareDir\b', 'defaultMantraDir'), + (r'\bCurareTheme\b', 'TorchTheme'), # Mantra's theme is still TorchTheme; change here if Phase 0 renames it + (r'Res\.string\.curare\b', 'Res.string.mantra'), + (r'Curare', 'Mantra'), + (r'const val APP_NAME = "Curare"', 'const val APP_NAME = "Torch"'), # and here + (r'const val CLIENT_NAME = "Curare"', 'const val CLIENT_NAME = "Torch"'), # and here + (r'APP_DIR_NAME = "curated"', 'APP_DIR_NAME = "mantra"'), + (r'rootProject\.name = "Curare"', 'rootProject.name = "Mantra"'), + (r'namespace = "to\.curare\.android"', 'namespace = "press.mantra.android"'), + (r'applicationId = "to\.curare\.android"', 'applicationId = "press.mantra.android"'), + # generation 1: com.it.curated / Curated* app identifiers (the two group-identity commits) + (r'com\.it\.curated\.', 'press.mantra.'), + (r'com/it/curated/', 'press/mantra/'), + (r'curated\.composeapp\.generated\.resources', 'mantra.composeapp.generated.resources'), + (r'\bCuratedDatabaseConstructor\b', 'MantraDatabaseConstructor'), + (r'\bCuratedDatabaseJvmTest\b', 'MantraDatabaseJvmTest'), + (r'\bCuratedDatabase\b', 'MantraDatabase'), + (r'\bCuratedApplication\b', 'MantraApplication'), + (r'\bCuratedApp\b', 'MantraApp'), + (r'\bCuratedGlobal\b', 'MantraGlobal'), + (r'\bcuratedGlobal\b', 'mantraGlobal'), + (r'\bCuratedNavHost\b', 'MantraNavHost'), + (r'\bCuratedNavigationSuite\b', 'MantraNavigationSuite'), + (r'\bCuratedShapes\b', 'MantraShapes'), + (r'\bCuratedConverters\b', 'MantraConverters'), + (r'\bdefaultCuratedDir\b', 'defaultMantraDir'), + (r'\bCuratedTheme\b', 'TorchTheme'), + (r'const val APP_NAME = "Curated"', 'const val APP_NAME = "Torch"'), + (r'const val CLIENT_NAME = "Curated"', 'const val CLIENT_NAME = "Torch"'), + (r'rootProject\.name = "Curated"', 'rootProject.name = "Mantra"'), + (r'namespace = "com\.it\.curated\.android"', 'namespace = "press.mantra.android"'), + (r'applicationId = "com\.it\.curated"', 'applicationId = "press.mantra.android"'), + # ios: Mantra never left Torch / ac.aux.compose.Aux (see the plan's Phase 0) + (r'PRODUCT_NAME=Curare\b', 'PRODUCT_NAME=Torch'), (r'PRODUCT_NAME=Curated\b', 'PRODUCT_NAME=Torch'), + (r'PRODUCT_BUNDLE_IDENTIFIER=to\.curare\b', 'PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux'), + (r'PRODUCT_BUNDLE_IDENTIFIER=com\.it\.curated\b', 'PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux'), + (r'\bCurare\.app\b', 'Torch.app'), (r'\bCurated\.app\b', 'Torch.app'), + # string keys carrying the brand: `_` is a word character, so \b never fires inside them + (r'sign_in_is_not_yet_available_while_curare_is', 'sign_in_is_not_yet_available_while_mantra_is'), + (r'sign_in_is_not_yet_available_while_curated_is', 'sign_in_is_not_yet_available_while_mantra_is'), + (r'(? str: + for rx, rep in RULES: + s = rx.sub(rep, s) + return s + + +def normalise_path(rel: str) -> str: + """Directories first, then the identifier rules over the whole path (file names carry them too).""" + rel = (rel.replace('/to/curare/', '/press/mantra/').replace('/com/it/curated/', '/press/mantra/') + .replace('to.curare.compose.database.CurareDatabase', 'press.mantra.compose.database.MantraDatabase') + .replace('com.it.curated.compose.database.CuratedDatabase', 'press.mantra.compose.database.MantraDatabase')) + return normalise_text(rel) + + +def _is_text(path: str) -> bool: + return os.path.splitext(path)[1] in TEXT_EXT or os.path.basename(path) in ('.gitmodules', 'gradlew') + + +def rewrite_tree(root: str) -> None: + rewritten = 0 + for dirpath, _, filenames in os.walk(root): + if '/.git' in dirpath or dirpath.endswith('/.git'): + continue + for fn in filenames: + p = os.path.join(dirpath, fn) + if not _is_text(p) or os.path.islink(p): + continue + try: + s = open(p, encoding='utf-8').read() + except UnicodeDecodeError: + continue + t = normalise_text(s) + if t != s: + open(p, 'w', encoding='utf-8').write(t) + rewritten += 1 + moves = [] + for dirpath, _, filenames in os.walk(root): + if '/.git' in dirpath or dirpath.endswith('/.git'): + continue + for fn in filenames: + rel = os.path.relpath(os.path.join(dirpath, fn), root) + new = normalise_path(rel) + if new != rel: + moves.append((rel, new)) + for rel, new in moves: + dst = os.path.join(root, new) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.move(os.path.join(root, rel), dst) + for dirpath, _, _ in os.walk(root, topdown=False): + if dirpath != root and '/.git' not in dirpath and not os.listdir(dirpath): + os.rmdir(dirpath) + print(f'rewritten {rewritten} files, moved {len(moves)} paths', file=sys.stderr) + + +def rewrite_patches(paths) -> None: + for p in paths: + s = open(p, encoding='utf-8').read() + open(p, 'w', encoding='utf-8').write(normalise_text(s)) + + +if __name__ == '__main__': + if len(sys.argv) >= 3 and sys.argv[1] == 'tree': + rewrite_tree(os.path.abspath(sys.argv[2])) + elif len(sys.argv) >= 3 and sys.argv[1] == 'patch': + rewrite_patches(sys.argv[2:]) + else: + sys.exit(__doc__) From 4681ac1a035c34a04df9fb81a549f0afd78a7425 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 11:42:36 +0200 Subject: [PATCH 05/71] refactor: retire the brand before mantra, and keep the three records of it Phase 0 of docs/curated-to-mantra.md, item 2. The app has been Mantra since before this repository had a docs/ directory, and four things still said Torch, the brand before it: the theme composable, `TorchTheme`, at 116 sites; the user agent, `UserAgent.APP_NAME` and `CLIENT_NAME`; two error strings a user could actually be shown ("tell X to use Torch", "finished setting up Torch"); and iOS, where `Config.xcconfig` built `PRODUCT_NAME=Torch` under the bundle id `ac.aux.compose.Aux` and the project's product reference was still `Aux.app` -- two brands ago. All four now say Mantra. **This is done natively, before anything is pulled from the fork, so that the fork's theme maps onto a name that means something.** The Curated fork retired Torch itself in d26cf6c7 (`TorchTheme` -> `CuratedTheme`, later `CurareTheme`). The pull rewrites every fork commit into this repository's names, and until now the only honest target for `CurareTheme` was `TorchTheme` -- the brand before last -- which would have had every pulled screen wrapping itself in a name the app stopped using long ago. `MantraTheme` is what the rewrite maps to from here; docs/scripts/curated-unbrand.py's theme, `UserAgent` and iOS rules are changed in this commit for that reason, and the comment that said "change here if Phase 0 renames it" goes with them. Retiring Torch as part of the pull instead -- by taking the rewritten d26cf6c7 -- was rejected because that commit would then be a fork commit renaming something the fork never had, and because the iOS half of the debt was never the fork's to pay. **The user agent went last, and the reason it could go at all is written where the next reader will look.** `CLIENT_NAME` is the `client` tag on every relay list this app publishes, which is why it was left alone when the top bar was fixed: it goes on the wire, so it read as a network identity question. It is attribution and nothing derives from it, which is the difference between it and the two `mantra/` hash tags that must never move; the comment on the `HomeScreen` bar and the paragraph in material-design-conformance.md that both said "still says Torch" now say that, in order, rather than describing a state this commit ends. **Three records keep the old name on purpose.** material-design-conformance.md:278 records a finding -- the top bar once read "Torch" while the window title read "Mantra" -- and changing it would falsify the record. The paragraph at :654 is rewritten rather than deleted because it was a statement of current state, and the current state changed. `NavigationRoutingTest`'s "Introducing... Torch" fixture is the string a stranded user actually saw, and the test is about the stranding. Nothing else in the tree says Torch. **iOS is unverified.** The build gates the apple targets behind `isMacOsX` and this was built on linux; the xcconfig and pbxproj edits are the same shape the fork's went through, and this is the first time this repository has named itself there. Build it on a Mac before believing it. :composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean; :composeApp:jvmTest 736 tests, 0 failures; :composeApp:testDebugUnitTest 403 tests, 0 failures; :composeApp:m3Audit all budgets met. Co-Authored-By: Claude Opus 5 --- .../kotlin/press/mantra/compose/MantraApp.kt | 4 +-- .../mantra/compose/database/model/Profile.kt | 4 +-- .../intermdiate/LocalQuotedNostrEvent.kt | 4 +-- .../press/mantra/compose/network/UserAgent.kt | 4 +-- .../ui/composable/ActiveProfileScreen.kt | 2 +- .../ui/composable/AddArtifactScreen.kt | 4 +-- .../compose/ui/composable/AddChapterScreen.kt | 4 +-- .../compose/ui/composable/AddDialectScreen.kt | 4 +-- .../AddMemberToChatRoomConfirmationScreen.kt | 6 ++-- .../AddTranslationArtifactVersionScreen.kt | 4 +-- .../ui/composable/ArtifactDetailScreen.kt | 4 +-- .../ui/composable/ChapterDetailScreen.kt | 4 +-- .../ui/composable/ChatRoomCreationScreen.kt | 2 +- .../ui/composable/ChatRoomDetailScreen.kt | 4 +-- .../ui/composable/ChatRoomMessagingScreen.kt | 4 +-- .../ui/composable/CreateProfileScreen.kt | 2 +- .../compose/ui/composable/DkgRitualScreen.kt | 4 +-- .../ui/composable/FrostSigningScreen.kt | 4 +-- .../compose/ui/composable/HomeScreen.kt | 14 ++++---- .../composable/ImplementationPendingScreen.kt | 2 +- .../composable/KeyPackageManagementScreen.kt | 2 +- .../ui/composable/KeyRecoveryScreen.kt | 2 +- .../compose/ui/composable/LandingScreen.kt | 2 +- .../compose/ui/composable/LoadingScreen.kt | 2 +- .../ui/composable/NostrEventDetailScreen.kt | 4 +-- .../ui/composable/ProposalListScreen.kt | 4 +-- .../ui/composable/RecoveryPhraseScreen.kt | 4 +-- .../SearchMemberToAddToChatRoomScreen.kt | 4 +-- .../ui/composable/SearchResultScreen.kt | 2 +- .../compose/ui/composable/SearchScreen.kt | 4 +-- .../composable/SelectChatRoomMembersScreen.kt | 4 +-- .../ui/composable/SelectChatRoomTypeScreen.kt | 4 +-- .../composable/SelectSubgroupAdminsScreen.kt | 4 +-- .../ui/composable/ShareProfileScreen.kt | 2 +- .../compose/ui/composable/SignInScreen.kt | 2 +- .../ui/composable/SocialPreconditionScreen.kt | 2 +- .../ui/composable/TranslateChunkScreen.kt | 4 +-- .../TranslationArtifactVersionDetailScreen.kt | 4 +-- .../ui/composable/TranslationChapterScreen.kt | 4 +-- .../ui/composable/UnannouncedProfileScreen.kt | 2 +- .../ui/composable/UnindexedProfileScreen.kt | 2 +- .../ui/composable/UnqueuedProfileScreen.kt | 2 +- .../UnqueuedProfileSynchronizationScreen.kt | 2 +- .../ui/composable/UnsignedProfileScreen.kt | 2 +- .../ui/composable/UnsyncedProfileScreen.kt | 2 +- .../ui/composable/WriteNewNoteScreen.kt | 2 +- .../widgets/LoadingDataIndicator.kt | 2 +- .../composable/widgets/content/QuotedNote.kt | 2 +- .../composable/widgets/content/RichContent.kt | 2 +- .../widgets/detail/MetadataEventDetail.kt | 4 +-- .../widgets/detail/TextNoteEventDetail.kt | 2 +- .../StartDirectMessageToNpubOrNip05Dialog.kt | 4 +-- .../composable/widgets/feed/EventListView.kt | 4 +-- .../widgets/feed/TextNoteFeedItem.kt | 6 ++-- .../widgets/profile/ProfileAvatar.kt | 2 +- .../mantra/compose/ui/theme/Breakpoint.kt | 8 ++--- .../compose/ui/theme/ConformancePreviews.kt | 2 +- .../press/mantra/compose/ui/theme/Motion.kt | 2 +- .../press/mantra/compose/ui/theme/Spacing.kt | 2 +- .../press/mantra/compose/ui/theme/Theme.kt | 6 ++-- .../mantra/compose/ui/theme/ThemeGallery.kt | 4 +-- ...ddMemberToChatRoomConfirmationViewModel.kt | 2 +- .../view/model/ChatRoomMessagingViewModel.kt | 2 +- .../kotlin/press/mantra/desktop/Main.kt | 8 ++--- .../ui/composable/ChatPaneLayoutJvmTest.kt | 4 +-- .../GroupKeyStateSheetLayoutJvmTest.kt | 6 ++-- .../NavigationSuiteRenderJvmTest.kt | 4 +-- .../widgets/ScreenStateTransitionJvmTest.kt | 6 ++-- .../ui/theme/NavigationMotionJvmTest.kt | 6 ++-- .../ui/theme/ReadableContentLayoutJvmTest.kt | 4 +-- docs/material-design-conformance.md | 32 +++++++++++-------- docs/scripts/curated-unbrand.py | 22 ++++++------- iosApp/Configuration/Config.xcconfig | 4 +-- iosApp/iosApp.xcodeproj/project.pbxproj | 6 ++-- 74 files changed, 161 insertions(+), 155 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt index bd2ce2d2..321545fa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import press.mantra.compose.ui.composable.navigation.MantraNavHost import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import fr.acinq.phoenix.PhoenixGlobal @Composable @@ -16,7 +16,7 @@ fun MantraApp( mantraGlobal: MantraGlobal, phoenixGlobal: PhoenixGlobal ) { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt index f6e7e758..f7d1ba98 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt @@ -23,7 +23,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.profile.ProfileColor -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import kotlin.time.Clock import kotlin.time.Instant import press.mantra.compose.ui.theme.ConformancePreviews @@ -180,7 +180,7 @@ fun ProfileRenderAsListItemPreview() { about = "I am the stone that the builder refused.", nostrEventId = "nostrEventId" ) - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(20.dp) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt index f016a4f1..a6c516a6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt @@ -21,7 +21,7 @@ import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.QuotedRelation import press.mantra.compose.ui.composable.widgets.content.RichContent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.ConformancePreviews data class LocalQuotedNostrEvent( @@ -113,7 +113,7 @@ data class LocalQuotedNostrEvent( @ConformancePreviews @Composable private fun LocalQuotedNostrEventPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(20.dp) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt index 87744cfd..557b63dc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/UserAgent.kt @@ -1,8 +1,8 @@ package press.mantra.compose.network object UserAgent { - const val APP_NAME = "Torch" - const val CLIENT_NAME = "Torch" + const val APP_NAME = "Mantra" + const val CLIENT_NAME = "Mantra" } fun String.asClientTag(): Array = arrayOf( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index 3f90c02c..ecbb13ae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -395,7 +395,7 @@ fun ActiveProfileScreen( @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index c3b1df70..693afa49 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -59,7 +59,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.repository.MantraRepository @@ -405,7 +405,7 @@ fun AddArtifactScreen( @ConformancePreviews @Composable private fun AddArtifactScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index d2627f34..f46431ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -49,7 +49,7 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddChapterViewModel import press.mantra.compose.ui.view.state.AddChapterUIState import press.mantra.compose.ui.theme.spacing @@ -313,7 +313,7 @@ fun AddChapterScreen( @ConformancePreviews @Composable private fun AddChapterScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { AddChapterScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index ee088cd2..f352dd58 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -51,7 +51,7 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddDialectViewModel import press.mantra.compose.ui.view.state.AddDialectUIState import press.mantra.compose.ui.theme.spacing @@ -340,7 +340,7 @@ fun AddDialectScreen( @ConformancePreviews @Composable private fun AddDialectScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt index 4c6cca36..d8c93b38 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt @@ -32,7 +32,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddMemberToChatRoomConfirmationViewModel import press.mantra.compose.ui.view.state.AddMemberToChatRoomConfirmationUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -217,7 +217,7 @@ fun AddMemberToChatRoomConfirmationScreen( @ConformancePreviews @Composable private fun ChatRoomDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -227,7 +227,7 @@ private fun ChatRoomDetailScreenPreview() { chatRoomId = "publicKey", relayHint = null, initialChatRoomDetailUIState = -// AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event.\n\nTry again later or tell Person to use Torch."), +// AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event.\n\nTry again later or tell Person to use Mantra."), AddMemberToChatRoomConfirmationUIState.Loaded( localChatRoom = LocalChatRoom( chatRoom = ChatRoom( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 366a627a..7f46159b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -53,7 +53,7 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel import press.mantra.compose.ui.view.state.AddTranslationArtifactVersionUIState import press.mantra.compose.ui.theme.spacing @@ -317,7 +317,7 @@ fun AddTranslationArtifactVersionScreen( @ConformancePreviews @Composable private fun AddTranslationArtifactVersionScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { AddTranslationArtifactVersionScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 627f7b97..442aba14 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -44,7 +44,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ArtifactDetailViewModel import press.mantra.compose.ui.view.state.ArtifactDetailUIState import press.mantra.compose.ui.theme.spacing @@ -333,7 +333,7 @@ fun ArtifactDetailScreen( @ConformancePreviews @Composable private fun ArtifactDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { ArtifactDetailScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt index f4c91cfa..37ae0687 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChapterDetailViewModel import press.mantra.compose.ui.view.state.ChapterDetailUIState import press.mantra.compose.ui.theme.spacing @@ -199,7 +199,7 @@ fun ChapterDetailScreen( @ConformancePreviews @Composable private fun ChapterDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { ChapterDetailScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt index 6f938a16..008dfb7c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt @@ -175,7 +175,7 @@ fun ChatRoomCreationScreen( @ConformancePreviews @Composable private fun LoadingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 70340da0..e816e78b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -77,7 +77,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -1222,7 +1222,7 @@ private fun ReindexMarmotGroupEventsButton( @ConformancePreviews @Composable private fun ChatRoomMessagingScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 657dfab7..e2a70c8f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -52,7 +52,7 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomMessagingViewModel import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -466,7 +466,7 @@ fun ChatRoomMessagingScreen( @ConformancePreviews @Composable private fun ChatRoomMessagingScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt index 4720d7f8..da11d54a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt @@ -396,7 +396,7 @@ fun CreateProfileScreen( @ConformancePreviews @Composable fun CreateAccountScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index 75c07645..e246e90b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -63,7 +63,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileColor -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.ui.composable.navigation.routes.DkgJoinApprovalRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRound1ApprovalRoute @@ -865,7 +865,7 @@ private fun QuorumStepper( @ConformancePreviews @Composable private fun DkgRitualScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { DkgRitualScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 00efaa4f..f9917824 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -52,7 +52,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.FrostSigningViewModel import press.mantra.compose.ui.view.state.FrostSigningUIState import press.mantra.compose.ui.theme.spacing @@ -410,7 +410,7 @@ private fun statusOf(session: FrostSigningSession): String = when (session.stage @ConformancePreviews @Composable private fun FrostSigningScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { FrostSigningScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index cd40abc7..fc20e8ec 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -48,7 +48,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.dialogs.NewChatBottomSheetDialog import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomListViewModel import press.mantra.compose.ui.view.model.HomeScreenType import press.mantra.compose.ui.view.model.HomeViewModel @@ -155,10 +155,12 @@ fun HomeScreen( title = { // "Mantra". The launcher label, the desktop window title, the // landing screen and the package name all say so; this bar was - // the last place still saying "Torch". `UserAgent.APP_NAME` is - // the other, and is left alone -- it goes on the wire to relay - // operators, so it is a network identity question rather than - // a content one. + // the last place still saying "Torch", the brand before this + // one, and `UserAgent.CLIENT_NAME` was the other. That one went + // last, because it goes on the wire: it is the `client` tag on + // every relay list this app publishes. It could move because it + // is attribution and nothing derives from it -- unlike the two + // `mantra/` hash tags, which must never move at all. Text(stringResource(Res.string.mantra)) }, // No navigation icon and no actions. The avatar in the leading @@ -350,7 +352,7 @@ fun HomeScreen( @ConformancePreviews @Composable private fun HomeScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt index 364a5477..cd4c2015 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt @@ -47,7 +47,7 @@ fun ImplementationPendingScreen( @ConformancePreviews @Composable private fun ImplementationPendingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt index 67c481f0..e787472d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt @@ -246,7 +246,7 @@ fun KeyPackageManagementScreen( @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index 5e2e387b..7d5f3396 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -299,7 +299,7 @@ private fun KeyRecoveryOption( @ConformancePreviews @Composable private fun KeyRecoveryScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt index 4be7fa3d..9b594afc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt @@ -115,7 +115,7 @@ fun LandingScreen( @ConformancePreviews @Composable private fun LandingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt index 47e1115c..4d44721a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt @@ -31,7 +31,7 @@ fun LoadingScreen( @ConformancePreviews @Composable private fun LoadingScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt index 1483e70b..348a912a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt @@ -23,7 +23,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.detail.MetadataEventDetail import press.mantra.compose.ui.composable.widgets.detail.TextNoteEventDetail -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.NostrEventDetailViewModel import press.mantra.compose.ui.view.state.NostrEventDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -149,7 +149,7 @@ fun NostrEventDetailScreen( @ConformancePreviews @Composable private fun NostrEventDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt index 0ad4346f..2ac182c3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -50,7 +50,7 @@ import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ProposalListViewModel import press.mantra.compose.ui.view.state.ProposalListUIState import press.mantra.compose.ui.theme.spacing @@ -418,7 +418,7 @@ private fun ProposalListScreenPreview() { signerId = 1 ) - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { ProposalListScreen( activeUserPublicKey = "u".repeat(64), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt index 9dae5c95..904c3768 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt @@ -460,7 +460,7 @@ private fun RecoveryPhraseCheckbox( @ConformancePreviews @Composable private fun RecoveryPhraseScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { @@ -487,7 +487,7 @@ private fun RecoveryPhraseScreenPreview() { @ConformancePreviews @Composable private fun RecoveryPhraseScreenHiddenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt index f0215c77..67b25221 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt @@ -35,7 +35,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddMemberToChatRoomC import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SearchMemberToAddToChatRoomViewModel import press.mantra.compose.ui.view.state.SearchMemberToAddToChatRoomUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -226,7 +226,7 @@ fun SearchMemberToAddToChatRoomScreen( @ConformancePreviews @Composable private fun ChatRoomDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt index 0e6d0793..83afd9f3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt @@ -284,7 +284,7 @@ fun SearchResultScreen( @ConformancePreviews @Composable private fun SearchResultScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt index 295fb2c6..02d31c75 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt @@ -58,7 +58,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.profile.ProfileColor -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SearchViewModel import press.mantra.compose.ui.view.state.SearchUIState import press.mantra.compose.ui.theme.spacing @@ -426,7 +426,7 @@ fun SearchScreen( @ConformancePreviews @Composable private fun SearchScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index 4fb9af0a..f1aa9dbc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -37,7 +37,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SelectChatRoomMembersViewModel import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -283,7 +283,7 @@ fun SelectChatRoomMembersScreen( @ConformancePreviews @Composable private fun SelectChatRoomMembersScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 9f58557e..b31969d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -47,7 +47,7 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SelectChatRoomTypeViewModel import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -465,7 +465,7 @@ private fun QuorumPicker( @ConformancePreviews @Composable private fun SelectChatRoomTypeScreenPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index c2baba8c..dd9028bb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -62,7 +62,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.view.model.SelectSubgroupAdminsViewModel @@ -326,7 +326,7 @@ private fun QuorumStepper( @ConformancePreviews @Composable private fun SelectSubgroupAdminsScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { SelectSubgroupAdminsScreen( activeUserPublicKey = "a".repeat(64), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt index eeb75341..b458efb5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt @@ -230,7 +230,7 @@ fun ShareProfileScreen( @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index ae53143f..8bc2e213 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -45,7 +45,7 @@ fun SignInToProfileScreen() { @ConformancePreviews @Composable private fun SignInToProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt index 4d310826..ab33152d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt @@ -127,7 +127,7 @@ fun SocialPreconditionScreen( @ConformancePreviews @Composable private fun SocialPreconditionScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt index 72ec6024..0d767b3d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -49,7 +49,7 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.TranslateChunkViewModel import press.mantra.compose.ui.view.state.TranslateChunkUIState import press.mantra.compose.ui.theme.spacing @@ -270,7 +270,7 @@ fun TranslateChunkScreen( @ConformancePreviews @Composable private fun TranslateChunkScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { TranslateChunkScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 52a86552..fef9c9d2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -42,7 +42,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute import press.mantra.compose.ui.composable.widgets.DetailRow import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.TranslationArtifactVersionDetailViewModel import press.mantra.compose.ui.view.state.TranslationArtifactVersionDetailUIState import press.mantra.compose.ui.theme.spacing @@ -287,7 +287,7 @@ fun TranslationArtifactVersionDetailScreen( @ConformancePreviews @Composable private fun TranslationArtifactVersionDetailScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { TranslationArtifactVersionDetailScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index 79f3a62a..1d0b5df7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -42,7 +42,7 @@ import press.mantra.compose.repository.MantraRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.TranslationChapterViewModel import press.mantra.compose.ui.view.state.ChunkTranslationPair import press.mantra.compose.ui.view.state.TranslationChapterUIState @@ -245,7 +245,7 @@ private fun TableRow( @ConformancePreviews @Composable private fun TranslationChapterScreenPreview() { - TorchTheme { + MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { TranslationChapterScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt index 8a03a051..2b33e12e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt @@ -79,7 +79,7 @@ fun UnannouncedProfileScreen() { @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt index 44bae335..cb63f8b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt @@ -79,7 +79,7 @@ fun UnindexedProfileScreen() { @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt index 96f918ce..e3b1c6b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt @@ -79,7 +79,7 @@ fun UnqueuedProfileScreen() { @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt index 6bbcbb9f..f45a39d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt @@ -91,7 +91,7 @@ fun UnqueuedProfileSynchronizationScreen( @ConformancePreviews @Composable private fun UnqueuedProfileSynchronizationScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt index fdafc1d0..a6b1a699 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt @@ -73,7 +73,7 @@ fun UnsignedProfileScreen() { @ConformancePreviews @Composable private fun UnsignedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index f59d26ea..0fa869a7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -75,7 +75,7 @@ fun UnsyncedProfileScreen( @ConformancePreviews @Composable private fun UnsyncedProfileScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt index 00bce1e0..bdef27a3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt @@ -468,7 +468,7 @@ fun WriteNewNoteScreen( @ConformancePreviews @Composable private fun WriteNewNoteScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt index 98737303..6c36e7f4 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt @@ -69,7 +69,7 @@ fun LoadingDataIndicator( @ConformancePreviews @Composable private fun TransferHistoryScreenPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface(modifier = Modifier.fillMaxSize()) { LoadingDataIndicator( text = "Starting up" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt index 597b51ff..551187ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt @@ -120,7 +120,7 @@ fun QuotedNote( @ConformancePreviews @Composable private fun QuotedNotePreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt index 40f4ab4b..73f0903f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt @@ -401,7 +401,7 @@ fun RichContent( @ConformancePreviews @Composable private fun RichContentPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index ccadaa71..2ad86228 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt @@ -50,7 +50,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRou import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.MetadataEventDetailType import press.mantra.compose.ui.view.model.MetadataEventDetailViewModel import press.mantra.compose.ui.view.state.MetadataEventDetailUIState @@ -326,7 +326,7 @@ fun MetadataEventDetail( @Composable private fun MetadataEventEventDetailPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt index 34927ce1..35304a9f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt @@ -393,7 +393,7 @@ fun TextNoteEventDetail( @Composable private fun TextNoteEventDetailPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt index ca97f935..9fe197c1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt @@ -36,7 +36,7 @@ import press.mantra.compose.extensions.bech32ToHexOrNull import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.utils.Parser import kotlinx.coroutines.CoroutineScope @@ -190,7 +190,7 @@ fun StartDirectMessageToNpubOrNip05Dialog( @ConformancePreviews @Composable private fun StartDirectMessageToNpubOrNip05DialogPreview() { - TorchTheme { + MantraTheme { Surface( modifier = Modifier.fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt index b74c6f2e..d825d9a3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt @@ -129,7 +129,7 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( @ConformancePreviews @Composable private fun EventListViewRepostedPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { @@ -180,7 +180,7 @@ private fun EventListViewRepostedPreview() { @ConformancePreviews @Composable private fun EventListViewInReplyPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt index bbe77197..5cbbfa60 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt @@ -187,7 +187,7 @@ internal fun TextNoteFeedItem( @ConformancePreviews @Composable private fun TextNoteFeedItemPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { @@ -218,7 +218,7 @@ private fun TextNoteFeedItemPreview() { @ConformancePreviews @Composable private fun TextNoteFeedItemInReplyToPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { @@ -272,7 +272,7 @@ private fun TextNoteFeedItemInReplyToPreview() { @ConformancePreviews @Composable private fun TextNoteFeedItemQuoteNostrEventPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt index 24cd82b4..d0b1a198 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt @@ -93,7 +93,7 @@ fun ProfileAvatar( @ConformancePreviews @Composable fun ProfileAvatarPreview() { - press.mantra.compose.ui.theme.TorchTheme { + press.mantra.compose.ui.theme.MantraTheme { Surface( modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt index 5d210bf2..ee87c588 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt @@ -59,7 +59,7 @@ enum class Breakpoint(val minWidth: Dp) { * them. * * Total, including for a width below [Compact.minWidth]. This is called from - * [TorchTheme] on every composition, and a desktop window reports a zero size for + * [MantraTheme] on every composition, and a desktop window reports a zero size for * the frame before its first layout pass; throwing there would take the app down * on a resize rather than on anything a user did. */ @@ -71,7 +71,7 @@ enum class Breakpoint(val minWidth: Dp) { /** * The breakpoint the current window is in. * - * Provided by [TorchTheme] as [LocalBreakpoint], so screens read + * Provided by [MantraTheme] as [LocalBreakpoint], so screens read * `MaterialTheme.breakpoint` rather than calling this. It is public because the two * entry points that compose above the theme -- the desktop passphrase gate, and any * future splash -- have nowhere else to get it. @@ -79,7 +79,7 @@ enum class Breakpoint(val minWidth: Dp) { * **Why the window size and not `currentWindowAdaptiveInfo()`.** The latter also computes * a [androidx.compose.material3.adaptive.Posture] from the platform's fold state, which * on android means reaching for `WindowInfoTracker` and an `Activity`. This is called - * from [TorchTheme], which wraps every `@Preview` in the tree, and a preview context is + * from [MantraTheme], which wraps every `@Preview` in the tree, and a preview context is * not an activity. `currentWindowDpSize()` is `LocalWindowInfo` and `LocalDensity` and * nothing else, so it answers everywhere. The pane scaffolds ask for posture themselves, * where a fold genuinely changes the answer. @@ -90,7 +90,7 @@ fun currentBreakpoint(): Breakpoint = Breakpoint.ofWidth(currentWindowDpSize().w /** * Defaults to [Breakpoint.Compact] rather than throwing, because that is the layout every - * screen in this app was written against: a composable that never reaches a [TorchTheme] + * screen in this app was written against: a composable that never reaches a [MantraTheme] * should render as it did before breakpoints existed, not fail. */ val LocalBreakpoint = staticCompositionLocalOf { Breakpoint.Compact } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt index d1e56707..28d9e553 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview * is right in the default scheme is right in the high-contrast one by construction. A * per-screen high-contrast preview would be 51 more renders checking something already * proved, and there is no `@Preview` parameter for it anyway -- it needs - * `TorchTheme(contrast = …)` in the body. [ThemeGallery] covers the schemes once, over + * `MantraTheme(contrast = …)` in the body. [ThemeGallery] covers the schemes once, over * the components rather than over the screens. * * @see ThemeGallery diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt index 6022a524..2b612736 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt @@ -17,7 +17,7 @@ import androidx.navigation.NavBackStackEntry /** * Whether the platform has been asked to keep motion to a minimum. * - * Provided by [TorchTheme] as [LocalReducedMotion]; the transitions below read it, so a + * Provided by [MantraTheme] as [LocalReducedMotion]; the transitions below read it, so a * call site does not have to remember to. Reading it directly is for a screen animating * something the shared helpers do not cover. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt index f333eeda..cf702bb3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp * M3's spacing scale, and the semantic names layered over it. * * `MaterialTheme` has no spacing slot, so this rides a composition local beside it, the - * same way [ExtendedColors] does. [TorchTheme] provides it. + * same way [ExtendedColors] does. [MantraTheme] provides it. * * **The scale** is M3's own, an 8dp system where `space100 = 8dp`, including the sub-8 * nested units Material defines because its components need them. Numbers are from diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index d5c2c66f..68998d58 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -393,7 +393,7 @@ val unspecified_scheme = ColorFamily( * Brand colours that are not part of the generated scheme. * * `ColorScheme` has no slot for them -- M3 calls them extended colours and expects them to - * ride alongside -- so they travel on their own composition local, provided by [TorchTheme] + * ride alongside -- so they travel on their own composition local, provided by [MantraTheme] * from the same `darkTheme` the scheme is chosen with. Reading `isSystemInDarkTheme()` at * the call site instead would be subtly wrong: it would ignore a caller who passed * `darkTheme` explicitly, and a preview forcing dark would show light pills. @@ -408,7 +408,7 @@ data class ExtendedColors( * The extended colours for the current theme. * * Defaults to the light families rather than to `unspecified_scheme` so that anything - * composing outside [TorchTheme] renders a real colour instead of nothing. That should not + * composing outside [MantraTheme] renders a real colour instead of nothing. That should not * happen -- as of this commit nothing in the tree does -- but an invisible button is a worse * way to find out than a light-themed one. */ @@ -484,7 +484,7 @@ internal fun appColorScheme(darkTheme: Boolean, contrast: ThemeContrast): ColorS */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun TorchTheme( +fun MantraTheme( darkTheme: Boolean = isSystemInDarkTheme(), contrast: ThemeContrast = platformThemeContrast(), // Dynamic color is available on Android 12+ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt index e5e393a0..7f8af17c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt @@ -44,7 +44,7 @@ import press.mantra.compose.ui.composable.widgets.Decorative @Preview(name = "Six schemes", group = "schemes", widthDp = 1200, heightDp = 900) @Composable private fun ThemeGalleryPreview() { - // One render holding all six, not six renders. The contrast step is a `TorchTheme` + // One render holding all six, not six renders. The contrast step is a `MantraTheme` // parameter and no preview annotation can set it, so the combinations have to be laid // out in the body -- and laying them out side by side is what makes them comparable, // which is the only reason to look at them at all. @@ -70,7 +70,7 @@ private fun ThemeGalleryPreview() { */ @Composable fun ThemeGallery(darkTheme: Boolean, contrast: ThemeContrast) { - TorchTheme(darkTheme = darkTheme, contrast = contrast, dynamicColor = false) { + MantraTheme(darkTheme = darkTheme, contrast = contrast, dynamicColor = false) { Surface { Column( modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt index 456e364a..914cb8ab 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddMemberToChatRoomConfirmationViewModel.kt @@ -74,7 +74,7 @@ class AddMemberToChatRoomConfirmationViewModel( val lookupTimeout = launch { delay(RELAY_LOOKUP_TIMEOUT) addMemberToChatRoomConfirmationUIState = - AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event. Try again later or tell ${profile.humanReadableNameOrPubkey()} to use Torch.") + AddMemberToChatRoomConfirmationUIState.Error("Profile missing key package event. Try again later or tell ${profile.humanReadableNameOrPubkey()} to use Mantra.") } // Observe rather than read once: the key package usually lands a moment after diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt index 22682987..07937d03 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt @@ -123,7 +123,7 @@ class ChatRoomMessagingViewModel( val lookupTimeout = viewModelScope.launch { delay(RELAY_LOOKUP_TIMEOUT) chatRoomMessagingUIState = ChatRoomMessagingUIState.Error( - "Couldn't find this profile's chat details on the relays yet. They may not have finished setting up Torch." + "Couldn't find this profile's chat details on the relays yet. They may not have finished setting up Mantra." ) } diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt index 76885170..470a7ceb 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt @@ -40,7 +40,7 @@ import press.mantra.compose.MantraApp import press.mantra.compose.MantraGlobal import press.mantra.compose.PlatformContext import press.mantra.compose.defaultMantraDir -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import java.awt.Dimension import java.io.File @@ -80,16 +80,16 @@ fun main() = application { // 400x600 is the narrowest window the phase's acceptance widths name. LaunchedEffect(window) { window.minimumSize = Dimension(400, 600) } - // TorchTheme wraps both branches, not just the app. PassphraseGate used to sit + // MantraTheme wraps both branches, not just the app. PassphraseGate used to sit // beside MantraApp -- which applies the theme itself -- so it composed under the // default MaterialTheme and its colorScheme and typography were baseline M3 // rather than this app's. It is the first screen a desktop user sees. // - // MantraApp still applies TorchTheme, so the unlocked branch is wrapped twice. + // MantraApp still applies MantraTheme, so the unlocked branch is wrapped twice. // That is deliberate: android and ios enter through MantraApp and would lose the // theme entirely if it moved out, and a second application of the same values // costs one composition of a CompositionLocalProvider. - TorchTheme { + MantraTheme { if (unlocked) { val navController = rememberNavController() MantraApp( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt index b4443882..ac4d48b7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt @@ -18,7 +18,7 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.HomeScreenUIState import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test @@ -83,7 +83,7 @@ class ChatPaneLayoutJvmTest { @androidx.compose.runtime.Composable private fun Home() { - TorchTheme { + MantraTheme { // Required, and deliberately so: `LocalSnackbarHostState` throws rather than // defaulting, because a default would make every `notify` on a screen that // forgot the host a silent no-op. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt index 3f856919..4fc7e259 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt @@ -18,7 +18,7 @@ import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme /** * The one thing about this sheet that reading it does not settle. @@ -78,7 +78,7 @@ class GroupKeyStateSheetLayoutJvmTest { fun `the signed event wraps inside the sheet rather than running off it`() = runDesktopComposeUiTest(width = 800, height = 2400) { setContent { - TorchTheme { + MantraTheme { // The sheet reports a copy through the snackbar host, so it // needs one in scope the way every screen does. ProvideSnackbarHost { @@ -105,7 +105,7 @@ class GroupKeyStateSheetLayoutJvmTest { fun `a device holding no signed event still renders the state`() = runDesktopComposeUiTest(width = 800, height = 2400) { setContent { - TorchTheme { + MantraTheme { ProvideSnackbarHost { Box(modifier = Modifier.width(phoneWidth).testTag(SHEET)) { GroupKeyStateSheetContent( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt index 4082b769..afe085b7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt @@ -19,7 +19,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRou import press.mantra.compose.ui.composable.navigation.routes.HomeRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute import press.mantra.compose.ui.theme.Breakpoint -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import kotlin.test.Test /** @@ -131,7 +131,7 @@ class NavigationSuiteRenderJvmTest { profileNostrEventId: String?, navController: NavHostController = rememberNavController(), ) { - TorchTheme { + MantraTheme { MantraNavigationSuite( navController = navController, breakpoint = Breakpoint.Large, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt index c00c6add..6b1b0a03 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.MantraTheme import kotlin.test.Test /** Stand-ins for a screen's UI state: two classes, and data that changes within one. */ @@ -39,7 +39,7 @@ class ScreenStateTransitionJvmTest { runDesktopComposeUiTest(400, 600) { var state by mutableStateOf(Fixture.Loading) mainClock.autoAdvance = false - setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + setContent { MantraTheme { ScreenStateTransition(state) { Render(it) } } } mainClock.advanceTimeBy(16) onNodeWithText("loading").assertIsDisplayed() @@ -61,7 +61,7 @@ class ScreenStateTransitionJvmTest { fun `data changing inside one state does not animate`() = runDesktopComposeUiTest(400, 600) { var state by mutableStateOf(Fixture.Loaded("first")) mainClock.autoAdvance = false - setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + setContent { MantraTheme { ScreenStateTransition(state) { Render(it) } } } mainClock.advanceTimeBy(16) onNodeWithText("first").assertIsDisplayed() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt index 961150be..9cae71b2 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt @@ -75,12 +75,12 @@ class NavigationMotionJvmTest { controller = rememberNavController() // Through the theme's parameter rather than by providing the local // around it. Providing it outside is what this test did first, and - // `TorchTheme` silently overwrote it -- the reduced case read 54dp of - // slide. That is also why `reducedMotion` is a `TorchTheme` parameter + // `MantraTheme` silently overwrote it -- the reduced case read 54dp of + // slide. That is also why `reducedMotion` is a `MantraTheme` parameter // rather than something the theme only reads from the platform: the // desktop actual is a hardcoded `false`, and a value nothing can override // is a value nothing can test either. - TorchTheme(reducedMotion = reducedMotion) { + MantraTheme(reducedMotion = reducedMotion) { NavHost( navController = controller, startDestination = First, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt index 647c55af..bea41cd9 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt @@ -78,7 +78,7 @@ class ReadableContentLayoutJvmTest { var margin: Dp? = null runDesktopComposeUiTest(width = windowWidth, height = 900) { setContent { - TorchTheme { + MantraTheme { seen = MaterialTheme.breakpoint margin = MaterialTheme.spacing.screenMargin } @@ -109,7 +109,7 @@ class ReadableContentLayoutJvmTest { @Composable private fun TaggedContent() { - TorchTheme { + MantraTheme { Box(Modifier.readableContent()) { Box(Modifier.fillMaxWidth().height(8.dp).testTag(Content)) } diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index b8709b3a..50a2e1c0 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -121,7 +121,7 @@ Three gaps: today; the trap springs the first time an expressive component does. 2. **The medium and high contrast schemes are dead code.** All four are declared - `private val`; `TorchTheme` only ever selects `darkScheme` or `lightScheme`. + `private val`; `MantraTheme` only ever selects `darkScheme` or `lightScheme`. There is no plumbing to the platform's contrast setting, so the two schemes that would honour it are unreachable. The accessibility foundation's first principle is *honour individuals* — *"supporting varying preferences and @@ -138,7 +138,7 @@ Two entry points render outside the theme: under the **default** `MaterialTheme`. Its `MaterialTheme.colorScheme.error` and `typography.headlineSmall` are baseline M3, not this app's. It is the first screen a desktop user sees. -- `Profile.kt:183` and 50 other `@Preview` bodies wrap in `TorchTheme` by hand, +- `Profile.kt:183` and 50 other `@Preview` bodies wrap in `MantraTheme` by hand, which is correct, but there is no preview that exercises dark, high-contrast or a wide window — so nothing catches the two problems below. @@ -418,7 +418,7 @@ find-and-replace. Keep the existing hex values for the roles already defined so nothing shifts visually; this phase adds, it does not restyle. -2. **Reach the contrast schemes.** *Built.* `TorchTheme` takes a `ThemeContrast` +2. **Reach the contrast schemes.** *Built.* `MantraTheme` takes a `ThemeContrast` defaulted from a new `platformThemeContrast()` expect/actual, and the six-way selection table lives in common code as `appColorScheme`. The old `themeColorScheme` expect took four arguments and did both jobs; it splits into @@ -441,7 +441,7 @@ find-and-replace. 3. **Give the theme its other three slots.** *Built.* The expressive-vs-standard question was put to the product owner on 2026-09-08 and answered - **expressive**, so `TorchTheme` calls `MaterialExpressiveTheme` with all four + **expressive**, so `MantraTheme` calls `MaterialExpressiveTheme` with all four slots passed explicitly — colour, `MotionScheme.expressive()`, `MantraShapes`, `AuxTypography`. Explicitly, because `MaterialExpressiveTheme` would otherwise default them to `expressiveLightColorScheme()` and friends, which is Material's @@ -472,10 +472,10 @@ find-and-replace. `container`/`on`/`onContainer`. `CreateProfileScreen.kt:311` and `:329` then stop pairing them with `Color.White`/`Color.DarkGray` by eye. -6. **Wrap the desktop gate.** *Built.* `Main.kt` moves `TorchTheme` outside the +6. **Wrap the desktop gate.** *Built.* `Main.kt` moves `MantraTheme` outside the `unlocked` branch, so `PassphraseGate` — the first screen a desktop user sees — stops composing under the default `MaterialTheme`. `MantraApp` keeps its own - `TorchTheme`, so the unlocked branch is wrapped twice: android and ios enter + `MantraTheme`, so the unlocked branch is wrapped twice: android and ios enter through `MantraApp` and would lose the theme entirely if it moved out, and a second application of the same values costs one `CompositionLocalProvider`. @@ -651,8 +651,12 @@ the whole sentence is, and several of them are pluralisations — `(if (n == 2) "event" else "events")` — which want a real plural resource rather than a format argument. `m3-extract-formatted.py --remaining` lists them. -`UserAgent.APP_NAME` still says "Torch". It goes on the wire to relay operators, so it is -a network identity question rather than a content one. +`UserAgent.APP_NAME` and `CLIENT_NAME` said "Torch" until the pull described in +[curated-to-mantra.md](./curated-to-mantra.md) retired the brand for good, in its Phase 0. +They were left for last on purpose: `CLIENT_NAME` goes on the wire as the `client` tag on +every relay list this app publishes, so it was a network identity question rather than a +content one. It could move because it is attribution and nothing derives from it -- unlike +the two `mantra/` hash tags, which are inputs to key derivation and must never move. --- @@ -729,13 +733,13 @@ panes last because both were product decisions. see below. 2. **`Breakpoint`**, the five-value enum with `ofWidth` as a pure function so the - thresholds are assertable without a Compose runtime. `TorchTheme` classifies + thresholds are assertable without a Compose runtime. `MantraTheme` classifies once and provides `LocalBreakpoint`, so no two screens can disagree about the window they are both in. It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`, because the latter also computes a `Posture` from the platform's fold state — - on android, `WindowInfoTracker` and an activity. This call sits in `TorchTheme`, + on android, `WindowInfoTracker` and an activity. This call sits in `MantraTheme`, which wraps all 51 `@Preview` bodies in the tree, and a preview context is not an activity. @@ -950,9 +954,9 @@ reduced. And a crossfade is told from a cut by the one frame in which *both* states are composed. **A test found a design flaw rather than a bug.** The reduced case read 54dp of -slide at first, because the test provided `LocalReducedMotion` around `TorchTheme` +slide at first, because the test provided `LocalReducedMotion` around `MantraTheme` and the theme overwrote it. The fix is not in the test: `reducedMotion` is now a -`TorchTheme` parameter defaulted to the platform, exactly as `contrast` is, +`MantraTheme` parameter defaulted to the platform, exactly as `contrast` is, because a value nothing can override is a value nothing can test — and because the desktop actual is a hardcoded `false` that a settings screen will need to override anyway. @@ -1017,7 +1021,7 @@ finding that out is most of what it was worth. High contrast is deliberately not in the annotation. Contrast is a property of the scheme rather than of a screen, `ColorSchemeContrastTest` measures every pair in all six, and there is no `@Preview` parameter for it — it needs - `TorchTheme(contrast = …)` in the body. `ThemeGallery` covers the six once, + `MantraTheme(contrast = …)` in the body. `ThemeGallery` covers the six once, over components rather than screens, with `dynamicColor = false` because an android 12+ preview would otherwise paint all six columns from the wallpaper. It is the only place the medium and high contrast schemes can be seen at all. @@ -1104,7 +1108,7 @@ Phases 1–5 can be worked in parallel by different people if 1 lands first; `ExperimentalMaterial3ExpressiveApi` opt-ins and the pinned alpha both point expressive, but it is a product decision with a visible outcome, and it should be made deliberately rather than inherited from an import. -- **Whether dynamic colour should keep overriding the brand.** `TorchTheme` defaults +- **Whether dynamic colour should keep overriding the brand.** `MantraTheme` defaults `dynamicColor = true`, and on Android 12+ that wins unconditionally — so on essentially every current Android device, none of the six schemes below is used and the app renders in whatever the user's wallpaper produced. Verified on an API diff --git a/docs/scripts/curated-unbrand.py b/docs/scripts/curated-unbrand.py index 0b01fdd3..a415e543 100755 --- a/docs/scripts/curated-unbrand.py +++ b/docs/scripts/curated-unbrand.py @@ -43,11 +43,11 @@ _RULES = [ (r'\bCurareShapes\b', 'MantraShapes'), (r'\bCurareConverters\b', 'MantraConverters'), (r'\bdefaultCurareDir\b', 'defaultMantraDir'), - (r'\bCurareTheme\b', 'TorchTheme'), # Mantra's theme is still TorchTheme; change here if Phase 0 renames it + (r'\bCurareTheme\b', 'MantraTheme'), (r'Res\.string\.curare\b', 'Res.string.mantra'), (r'Curare', 'Mantra'), - (r'const val APP_NAME = "Curare"', 'const val APP_NAME = "Torch"'), # and here - (r'const val CLIENT_NAME = "Curare"', 'const val CLIENT_NAME = "Torch"'), # and here + (r'const val APP_NAME = "Curare"', 'const val APP_NAME = "Mantra"'), + (r'const val CLIENT_NAME = "Curare"', 'const val CLIENT_NAME = "Mantra"'), (r'APP_DIR_NAME = "curated"', 'APP_DIR_NAME = "mantra"'), (r'rootProject\.name = "Curare"', 'rootProject.name = "Mantra"'), (r'namespace = "to\.curare\.android"', 'namespace = "press.mantra.android"'), @@ -68,17 +68,17 @@ _RULES = [ (r'\bCuratedShapes\b', 'MantraShapes'), (r'\bCuratedConverters\b', 'MantraConverters'), (r'\bdefaultCuratedDir\b', 'defaultMantraDir'), - (r'\bCuratedTheme\b', 'TorchTheme'), - (r'const val APP_NAME = "Curated"', 'const val APP_NAME = "Torch"'), - (r'const val CLIENT_NAME = "Curated"', 'const val CLIENT_NAME = "Torch"'), + (r'\bCuratedTheme\b', 'MantraTheme'), + (r'const val APP_NAME = "Curated"', 'const val APP_NAME = "Mantra"'), + (r'const val CLIENT_NAME = "Curated"', 'const val CLIENT_NAME = "Mantra"'), (r'rootProject\.name = "Curated"', 'rootProject.name = "Mantra"'), (r'namespace = "com\.it\.curated\.android"', 'namespace = "press.mantra.android"'), (r'applicationId = "com\.it\.curated"', 'applicationId = "press.mantra.android"'), - # ios: Mantra never left Torch / ac.aux.compose.Aux (see the plan's Phase 0) - (r'PRODUCT_NAME=Curare\b', 'PRODUCT_NAME=Torch'), (r'PRODUCT_NAME=Curated\b', 'PRODUCT_NAME=Torch'), - (r'PRODUCT_BUNDLE_IDENTIFIER=to\.curare\b', 'PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux'), - (r'PRODUCT_BUNDLE_IDENTIFIER=com\.it\.curated\b', 'PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux'), - (r'\bCurare\.app\b', 'Torch.app'), (r'\bCurated\.app\b', 'Torch.app'), + # ios: Mantra is `Mantra` / press.mantra.ios since the plan's Phase 0 + (r'PRODUCT_NAME=Curare\b', 'PRODUCT_NAME=Mantra'), (r'PRODUCT_NAME=Curated\b', 'PRODUCT_NAME=Mantra'), + (r'PRODUCT_BUNDLE_IDENTIFIER=to\.curare\.ios', 'PRODUCT_BUNDLE_IDENTIFIER=press.mantra.ios'), + (r'PRODUCT_BUNDLE_IDENTIFIER=com\.it\.curated\b', 'PRODUCT_BUNDLE_IDENTIFIER=press.mantra.ios'), + (r'\bCurare\.app\b', 'Mantra.app'), (r'\bCurated\.app\b', 'Mantra.app'), # string keys carrying the brand: `_` is a word character, so \b never fires inside them (r'sign_in_is_not_yet_available_while_curare_is', 'sign_in_is_not_yet_available_while_mantra_is'), (r'sign_in_is_not_yet_available_while_curated_is', 'sign_in_is_not_yet_available_while_mantra_is'), diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 3faaa0ad..951f1f68 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -1,7 +1,7 @@ TEAM_ID= -PRODUCT_NAME=Torch -PRODUCT_BUNDLE_IDENTIFIER=ac.aux.compose.Aux$(TEAM_ID) +PRODUCT_NAME=Mantra +PRODUCT_BUNDLE_IDENTIFIER=press.mantra.ios$(TEAM_ID) CURRENT_PROJECT_VERSION=1 MARKETING_VERSION=1.0 \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 683c17c7..3a0c2f5e 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXFileReference section */ - 41CB266BF907B985085BE6EC /* Aux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Aux.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 41CB266BF907B985085BE6EC /* Mantra.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mantra.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -59,7 +59,7 @@ ECDAFDDED408A506DEFE0AF1 /* Products */ = { isa = PBXGroup; children = ( - 41CB266BF907B985085BE6EC /* Aux.app */, + 41CB266BF907B985085BE6EC /* Mantra.app */, ); name = Products; sourceTree = ""; @@ -87,7 +87,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = 41CB266BF907B985085BE6EC /* Aux.app */; + productReference = 41CB266BF907B985085BE6EC /* Mantra.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ From ba0830a3d47aee69c19c1abec8e8fa65813b7f34 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 11:42:56 +0200 Subject: [PATCH 06/71] docs: say why the two hash tags and the relay host must never be renamed Phase 0 of docs/curated-to-mantra.md, item 3. Three strings in this tree spell "mantra" for reasons that have nothing to do with the brand, and until now nothing beside them said so: `SharedKeyDerivation.TWEAK_TAG` and `ChillDkgRitualManager.HOST_KEY_DERIVATION_TAG` are inputs to hashes, and `Relays.ephemeral` is a relay that is running. Each now carries a comment saying what renaming it would cost, and docs/shared-key-derivation.md gets the paragraph that ties the three together. **The comments come from the fork, and are rewritten rather than pulled.** The Curated fork found out what these strings were the hard way: it renamed the app twice, and each time had to decide which of thousands of "mantra" tokens were the brand. Its rebrand commits (3bc8be53, e6aee792) left these three alone and wrote down why, and run through the pull's name-rewrite those commits collapse to almost nothing but those comments. They were not taken as commits, because what survives the rewrite is a sentence like "has survived two rebrands -- Mantra to Curated, Curated to Mantra", which in this repository describes rebrands that never happened. The fact they state from this side is different and worth stating plainly: the fork keeps all three byte for byte, so a Mantra member and a Curated member of one group derive one key and talk to one relay, and a rename *here* would split them as surely as a rename there. **Why comments at all, when the derivation note already has the rule.** The note's one rule is about the path a key is derived along; it never said that the tag string itself is part of the derivation, and the failure mode of renaming it is silent -- every room orphaned, every partial signature aggregating to nothing that verifies, and nothing on screen to say so. A `v2` tag is the shape a deliberate change would take, and the comments say so, so that the next person to grep for the brand finds the answer before the diff. **`ComposeAppCommonTest` moves from `press.auxiliary` to `press.mantra`.** It is the KMP template's `1 + 2 == 3`, the last file under a package the app vacated two brands ago, and the fork relocated it rather than deleting it so the source tree has one root package instead of an orphan under an empty one. Same here; it is moved with `git mv` so its history follows. No behaviour changes. :composeApp:jvmTest 736 tests, 0 failures; :composeApp:testDebugUnitTest 403 tests, 0 failures; :composeApp:m3Audit all budgets met. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/managers/ChillDkgRitualManager.kt | 6 ++++++ .../mantra/compose/managers/SharedKeyDerivation.kt | 10 ++++++++++ .../kotlin/press/mantra/compose/nostr/Relays.kt | 4 ++++ .../compose/ComposeAppCommonTest.kt | 2 +- docs/shared-key-derivation.md | 10 ++++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) rename composeApp/src/commonTest/kotlin/press/{auxiliary => mantra}/compose/ComposeAppCommonTest.kt (82%) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 5e2a9c6a..2f80f727 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -91,6 +91,12 @@ object ChillDkgRitualManager { * be the nostr identity key: reusing one secret across two protocols means a * flaw in either can reach the other. Deriving it from the same seed keeps it * recoverable from the wallet backup without being the same key. + * + * The `mantra/` prefix is not a brand string and must never follow one. It is an + * input to the hash, so renaming it derives a different host key from the same + * seed -- which loses the outputs of every DKG session already run, since ChillDKG + * needs the host secret key to recover them. The Curated fork of this app keeps it + * byte for byte. Renaming it is a protocol fork. */ private const val HOST_KEY_DERIVATION_TAG = "mantra/chilldkg/host-key/v1" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt index 8589f4eb..265bb79f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SharedKeyDerivation.kt @@ -61,6 +61,16 @@ object SharedKeyDerivation { * Domain separation for the tweak scalars. Versioned so a future scheme can * coexist with keys already derived under this one, and distinct from any other * use of the same threshold key. + * + * The `mantra/` prefix is not a brand string and must never follow one. It is an + * input to the derivation, so changing it changes every key derived under it, + * for the same reason [MARMOT_ADMIN_GROUP_PATH] has to stay put: every room + * already created would be orphaned, and a device on the old string and a device + * on the new one would derive different keys from the same threshold key, so + * their partial signatures would aggregate to nothing that verifies. The Curated + * fork of this app keeps this string byte for byte for exactly that reason -- a + * Mantra member and a Curated member of one group sign under one key. Renaming + * it is a protocol fork, and belongs in a `v2` tag if it is ever wanted. */ private const val TWEAK_TAG = "mantra/shared-key/tweak/v1" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt index 6007748b..12b68c84 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt @@ -6,6 +6,10 @@ import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType object Relays { + // A running relay, not a brand string: it is in the bootstrap, publish, finder and + // DM sets, so pointing it at a name nobody has stood up would take the app off the + // network. The Curated fork of this app still talks to this same host, so the two + // apps' users find each other here. val ephemeral = RelayUrlNormalizer.normalize("wss://ephemeral.mantra.press") val nos = RelayUrlNormalizer.normalize("wss://nos.lol") diff --git a/composeApp/src/commonTest/kotlin/press/auxiliary/compose/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ComposeAppCommonTest.kt similarity index 82% rename from composeApp/src/commonTest/kotlin/press/auxiliary/compose/ComposeAppCommonTest.kt rename to composeApp/src/commonTest/kotlin/press/mantra/compose/ComposeAppCommonTest.kt index 3e548c4a..b143f6d9 100644 --- a/composeApp/src/commonTest/kotlin/press/auxiliary/compose/ComposeAppCommonTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ComposeAppCommonTest.kt @@ -1,4 +1,4 @@ -package press.auxiliary.compose +package press.mantra.compose import kotlin.test.Test import kotlin.test.assertEquals diff --git a/docs/shared-key-derivation.md b/docs/shared-key-derivation.md index 2332f2f0..69a758e0 100644 --- a/docs/shared-key-derivation.md +++ b/docs/shared-key-derivation.md @@ -65,6 +65,16 @@ Each scalar commits to the key being tweaked as well as the index, so steps cann be reordered or replayed at a different depth to reach the same key. `listOf(0L)` and `listOf(0L, 0L, 0L)` do not collide — there is a test for it. +The `mantra/` prefix is deliberate and is not a brand string: the string is an input to +the hash, so renaming it derives different keys from the same threshold key — orphaning +every room already created, and splitting devices on the new string off from devices on +the old one, since their partial signatures would no longer aggregate to one that +verifies. The Curated fork of this app keeps it byte for byte for that reason, and so +must this one. A rename is a protocol fork and would need a `v2` tag, not an edit to +this one. The ChillDKG host-key tag in `ChillDkgRitualManager` is protected by the same +argument, and `Relays.ephemeral` stays on its `mantra.press` host for the duller reason +that it is a relay that is running. + ### What avoiding BIP32 also avoids With x-only keys there is no single obvious `serP(K_par)`: BIP32 serialises From 2a02fbc14f0d661cff530d57ee3565ea1ffdfd37 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 11:57:48 +0200 Subject: [PATCH 07/71] docs: record the day's dry run, and the three things it changed in the plan Phase 1 of docs/curated-to-mantra.md, run on 2026-09-13 on top of Phase 0, on a scratch worktree that landed nothing. The plan asked for exactly this -- redo the dry run the day you land, because the numbers drift -- and it was right to: the upstream had moved, the pipeline as written could not do phased landings, and the library pin turned out to have to move backwards. All three are now in the plan, in the Phase 1 section, as a record beside the plan rather than a rewrite of it. **The replay lands by cherry-pick in topological order, not by `rebase --rebase-merges`, and the reason is a property of git rather than a preference.** The plan's phases are cuts through a graph with four merges in it. Rebasing a cut whose merges have one parent in an earlier cut recreates each such merge against the *pre*-replay parent -- the commit in the rewritten source branch, not the one already landed -- and drags the unrebased lineage in beside the rebased one. A single whole-range rebase, which is what the plan's dry run measured, never meets this because every parent is inside the range. So: the ordinary commits are picked one at a time, the merge commits land as nothing, and their content, which is only ever conflict resolutions, is folded in where the conflicts actually surface. The driver that does it is docs/scripts/curated-replay.py, added here; every commit it lands carries a `Pulled-From: curated/curated@` trailer stamped by the rewrite, and a paragraph naming any resolution made on the way in. **At a branch join the join files are taken exactly as upstream's own merge left them, and a union was tried and rejected three times before that rule was reached.** A union -- keep both sides of the conflict -- is the obvious resolution for two branches appending strings to the same file, and it was wrong three ways, each caught by the exactness check against the rewritten tip and none of them visible in a passing build. It duplicates lines both sides already hold when a conflict hunk widens (thirteen string keys, twice). It never applies the other side's deletions, so the two copy-suggestion strings that fcc19f95 removes survived. And, the one that took longest to see, a join resolved in a different *order* from upstream's merge leaves every later commit that edits the block unable to find its base, so its deletions fail silently while its additions land -- which is why the second fix still left the same two strings behind. The rule that survives: files whose lines are unique by construction (string keys, imports, route registrations, table rows) get a line-set three-way merge over diff3 hunks -- ours, minus what theirs deleted from base, plus what theirs added that the file does not already hold anywhere -- and never at a join, where the file upstream's merge produced is the answer. The driver's docstring says all of this so the next reader does not rediscover it. **The library pin goes back to 59c11ed for Phases 3 and 4 and forward again in Phase 5, and the compiler was asked before deciding.** The nsec line was written against lightning-kmp-app 59c11ed and writes through its `NostrKeyManager`; 84cc44c, Mantra's pin, renamed that class to a read-only `LegacyNostrKeysFile`. Reasoning said the Phase 3 cut would not compile against 84cc44c; the trial worktree was put at that cut and built against it, and produced eight `Unresolved reference 'NostrKeyManager'` errors in IdentityWriter.kt. So 366b0177 moves the pin to 59c11ed, whose nested lightning-kmp -> bitcoin-kmp -> secp256k1 chain is the same commit as 84cc44c's and rebuilds nothing native, and 5efeae76 brings it to 84cc44c -- not to the 01962f3 it named upstream, which is a branch commit since rebased onto the library's master and no longer fetchable, but whose tree 84cc44c reproduces exactly. Every commit on the branch will compile against the pin it records, which is what upstream's history had and a fixed pin would have thrown away. Alternatives rejected: adapting the Phase 3 commits to the renamed class (rewriting upstream's work on the way in, and inventing an intermediate state nobody built) and landing Phases 3 to 5 as one unit whose inner commits do not build (bisect would hate it, and so would review). **The upstream moved, and the new line is the next pull rather than part of this one.** curated/curated went from 86cb876b, which the plan was written against, to 29027f2b: ten commits for several profiles on one device, one of which (759199f2) adds schema version 20, the fork's first migration. They are recorded and left out on purpose; a migration landing on Mantra's database deserves its own decision, and the plan's own principle -- pull the whole non-brand tree so the next pull is a replay -- says how that decision goes once it is made. The trial, with the committed driver: 30 commits replayed (4, 8, 6, 12), 21 clean and 9 with a resolution note, 0 stuck; the driver's own rerun from the Phase 0 tip reproduces the tree and improves on the hand-run trial by the one README line the old union had wrongly kept; residual diff against the rewritten 86cb876b exactly the known set -- Phase 0's prose, 39fb64b6's files, 808a3459's three, this document and its scripts, the logo. :composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean; :composeApp:jvmTest 1,039 tests, 0 failures; :composeApp:testDebugUnitTest 530 tests, 0 failures; :composeApp:m3Audit all budgets met. Co-Authored-By: Claude Opus 5 --- docs/curated-to-mantra.md | 48 +++++++++++ docs/scripts/curated-replay.py | 148 +++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100755 docs/scripts/curated-replay.py diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md index bcd69bd8..6865de7c 100644 --- a/docs/curated-to-mantra.md +++ b/docs/curated-to-mantra.md @@ -456,6 +456,54 @@ touched a seam; a non-empty exactness diff means a new brand token the normalise not know. Delete the worktree and the `tmp/` branches when done; the script recreates them in under two minutes. +**Run on 2026-09-13, after Phase 0, and it changed the plan in three places.** The +record, so the next pull starts from what happened rather than from what was planned: + +*The upstream moved first.* `curated/curated` was `86cb876b` when the plan was written +and `29027f2b` when Phase 1 ran: ten more commits, a "multiple profiles on a device" +line, one of which (`759199f2`) adds schema version 20 — the fork's first migration. +They are outside this plan's scope and are the next pull; the schema change is why +they get a decision of their own rather than a ride on this one. + +*Landing is by cherry-pick in topological order, not by `rebase --rebase-merges`.* The +phased cuts cannot be expressed as rebases: a recreated merge whose other parent was +replayed in an earlier phase would be pointed at the *pre*-replay commit, dragging the +unrebased lineage in beside the rebased one. So the ordinary commits are picked one by +one, the four merge commits land as nothing, and their content — the resolutions — is +folded in where the conflicts actually surface: at each branch join the join files +(`strings.xml`, `MantraNavHost.kt`, `CuratedSuggestionListScreen.kt`) are taken exactly +as upstream's own merge left them, and the two files the merges edited outside their +conflicts are folded into the back-button commit. Every landed commit carries a +`Pulled-From: curated/curated@` trailer, stamped by the rewrite, and a paragraph +naming any resolution made on the way in. The driver is +[`docs/scripts/curated-replay.py`](./scripts/curated-replay.py). + +*The library pin goes backwards for Phases 3 and 4, on purpose.* The nsec line was +written against lightning-kmp-app `59c11ed`, whose `NostrKeyManager` it writes through; +`84cc44c` — Mantra's pin — renamed that class to a read-only `LegacyNostrKeysFile`, and +the compiler says so at the Phase 3 cut (`Unresolved reference 'NostrKeyManager'` in +`IdentityWriter.kt`, eight errors). So `366b0177` moves the pin back to `59c11ed`, whose +nested chain is identical, and `5efeae76` brings it forward again — to `84cc44c` rather +than the `01962f3` it named upstream, which is an unfetchable branch commit whose tree +`84cc44c` reproduces exactly. Every commit on the branch compiles against the pin it +records, which is the property upstream's history had and a fixed pin would have lost. + +*Three ways a "keep both sides" resolution is wrong,* each caught by the exactness +check and each now handled: it duplicates lines both sides already hold when a hunk +widens; it never applies the other side's deletions (the copy-suggestion strings +`fcc19f95` removes survived one attempt); and, subtlest, a join resolved in a different +*order* from upstream's merge leaves later commits unable to find the block they edit, +so their deletions silently miss. Hence: conflicts are picked with `diff3` markers and +resolved as a line-set three-way merge for files whose lines are unique by +construction, and never at a join, where upstream's merge is the answer. + +The numbers, on the trial worktree: 30 commits replayed (4, 8, 6, 12), 0 stuck, 9 +carrying a resolution note; residual diff against the rewritten `86cb876b` exactly +the known set (Phase 0's prose, `39fb64b6`'s files, `808a3459`'s three, this document +and its scripts); `compileDebugKotlinAndroid` and `compileKotlinJvm` clean; +`jvmTest` 1,039 tests, `testDebugUnitTest` 530, both 0 failures; `m3Audit` all budgets +met. + ### Phase 2 — The group's identity, and the lists' read side Cut at `392b90b8'`: `4c0ed0c1`, `334e6dd1`, `930d37c8`, `392b90b8`, with `808a3459` diff --git a/docs/scripts/curated-replay.py b/docs/scripts/curated-replay.py new file mode 100755 index 00000000..f0ac19dd --- /dev/null +++ b/docs/scripts/curated-replay.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Land rewritten Curated commits on a Mantra branch, one cherry-pick at a time. + +The companion of curated-unbrand.py, and the second half of docs/curated-to-mantra.md's +pipeline as it was actually run. The rewrite (filter-branch with curated-unbrand.py as the +tree filter and a `Pulled-From: curated/curated@` trailer stamped by its message +filter) leaves a branch `tmp/curated-src` in Mantra's names; this replays commits off it +by their *original* sha, in the order you give, onto the current branch of . + + curated-replay.py pick ... cherry-pick each, resolving by the rules below + curated-replay.py check files differing from the rewritten cut + +Resolutions are rules, and anything the rules do not cover stops the run with the commit +aborted, so a new conflict is a decision rather than a guess. What the rules encode: + + * a branch join -- the first commit off one side of an upstream merge landing on top of + the other side -- is resolved by taking the join files exactly as upstream's own merge + left them (TAKE_FROM). Not a union: a union orders the two appended blocks its own way, + and every later commit that edits the block then misses its base and its deletions + silently fail. + * files whose lines are unique by construction (string keys, imports, route + registrations, table rows) get a line-set three-way merge over diff3 hunks (UNION): + ours, minus what theirs deleted from base, plus what theirs added that the file does + not already hold anywhere. + * the library pin follows the commit being replayed (PIN), because each upstream commit + compiled against the pin it recorded; `5efeae76` named a branch commit that no longer + exists and is mapped to the master commit with the identical tree. + * a handful of (commit, file) pairs where one side simply wins (THEIRS, DELETE), each + with the reason written into the commit. + +Every resolution is appended to the landed commit's message as a paragraph, ahead of the +trailers, so the branch explains itself. +""" +import os, re, subprocess, sys + +STRINGS = 'composeApp/src/commonMain/composeResources/values/strings.xml' +NAVHOST = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt' +UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/' +GITLINK = 'lightning-kmp-app' + +UNION = {STRINGS, NAVHOST, 'docs/README.md'} +PIN = { + '366b0177': '59c11ed926ac05c820b98afb4cc961a5a3506310', # the nsec line was written against 59c11ed's NostrKeyManager + '5efeae76': '84cc44c855e3d173e26016b807c449153a3af597', # upstream: 01962f3, a rebased-away branch commit with this tree + '00c36ec5': '84cc44c855e3d173e26016b807c449153a3af597', +} +TAKE_FROM = { # (orig, path) -> the original merge commit whose rewritten tree supplies the file + ('1e52fc8f', STRINGS): 'c8de3a1f', ('1e52fc8f', NAVHOST): 'c8de3a1f', + ('0634487e', UI + 'CuratedSuggestionListScreen.kt'): 'fedbe724', + ('0634487e', STRINGS): 'fedbe724', ('0634487e', NAVHOST): 'fedbe724', +} +AFTER = { # orig -> files folded in after the pick, from the merge that edited them outside its conflicts + '0634487e': [(UI + 'AcceptCuratedSuggestionScreen.kt', 'fedbe724'), (UI + 'BroadcastGroupSignedEventScreen.kt', 'fedbe724')], +} +THEIRS = { + ('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt'), + ('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt'), + ('e3cc23ae', UI + 'KeyRecoveryScreen.kt'), +} +THEIRS_WHY = { + '42f3a697': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served", + 'e3cc23ae': 'the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it', +} +DELETE = {('55664cc7', 'composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt')} + + +def main(): + mode, wt, args = sys.argv[1], sys.argv[2], sys.argv[3:] + def git(*a, check=True, **kw): + return subprocess.run(['git', *a], cwd=wt, capture_output=True, text=True, check=check, **kw) + rewritten = {} + for line in git('log', '--format=%H %(trailers:key=Pulled-From,valueonly)', 'origin/mantra..tmp/curated-src').stdout.splitlines(): + parts = line.split() + if len(parts) == 2: rewritten[parts[1].split('@')[-1][:8]] = parts[0] + rw = lambda orig: rewritten[orig[:8]] + + if mode == 'check': + print(git('diff', '--name-only', rw(args[0]), 'HEAD').stdout, end=''); return + + def union_resolve(path): + p = os.path.join(wt, path); lines = open(p, encoding='utf-8').read().split('\n') + state = None; outside = set() + for l in lines: + if l.startswith('<<<<<<< '): state = 'in'; continue + if l.startswith('>>>>>>> '): state = None; continue + if state is None: outside.add(l) + out = []; state = None; ours = base = theirs = None + for l in lines: + if l.startswith('<<<<<<< '): state = 'ours'; ours, base, theirs = [], [], []; continue + if l.startswith('||||||| ') and state == 'ours': state = 'base'; continue + if l == '=======' and state in ('ours', 'base'): state = 'theirs'; continue + if l.startswith('>>>>>>> ') and state == 'theirs': + removed = {b for b in base if b not in theirs and b.strip()} + added = [t for t in theirs if t not in base] + kept = [o for o in ours if o not in removed] + have = outside | set(kept) + out.extend(kept); out.extend(a for a in added if a not in have or a.strip() == ''); state = None; continue + if state == 'ours': ours.append(l) + elif state == 'base': base.append(l) + elif state == 'theirs': theirs.append(l) + else: out.append(l) + open(p, 'w', encoding='utf-8').write('\n'.join(out)) + + def amend_note(note): + msg = git('log', '-1', '--format=%B').stdout.rstrip('\n').split('\n'); i = len(msg) + while i > 0 and re.match(r'^[A-Za-z-]+: ', msg[i - 1]): i -= 1 + body, trailers = msg[:i], msg[i:] + while body and body[-1] == '': body.pop() + subprocess.run(['git', 'commit', '-q', '--amend', '-F', '-'], cwd=wt, input='\n'.join(body + ['', note, ''] + trailers) + '\n', text=True, check=True) + + for orig in args: + o8 = orig[:8]; sha = rw(o8); subject = git('log', '-1', '--format=%s', sha).stdout.strip(); notes = [] + r = git('-c', 'merge.conflictStyle=diff3', 'cherry-pick', '--allow-empty', sha, check=False) + if r.returncode != 0: + stuck = [] + for f in git('diff', '--name-only', '--diff-filter=U').stdout.split(): + if f == GITLINK and o8 in PIN: + git('update-index', '--cacheinfo', f'160000,{PIN[o8]},{GITLINK}'); notes.append(f'gitlink -> {PIN[o8][:8]}, the pin this commit compiles against') + elif (o8, f) in TAKE_FROM: + src = TAKE_FROM[(o8, f)]; open(os.path.join(wt, f), 'w').write(git('show', f'{rw(src)}:{f}').stdout); git('add', f) + notes.append(f'{os.path.basename(f)}: taken as the original merge {src} left it, this being the branch join') + elif (o8, f) in THEIRS: + git('checkout', '--theirs', '--', f); git('add', f); notes.append(f'{os.path.basename(f)}: {THEIRS_WHY[o8]}') + elif (o8, f) in DELETE: + git('rm', '-q', '-f', f); notes.append(f'{os.path.basename(f)}: deleted, as this commit deletes it upstream') + elif f in UNION: + union_resolve(f); git('add', f); notes.append(f'{os.path.basename(f)}: line-set three-way merge, both sides\' additions kept and this commit\'s deletions applied') + else: + stuck.append(f) + if stuck: + git('cherry-pick', '--abort', check=False); sys.exit(f'STUCK at {o8} {subject}: no rule for {stuck}') + r2 = subprocess.run(['git', '-c', 'core.editor=true', 'cherry-pick', '--continue'], cwd=wt, capture_output=True, text=True) + if r2.returncode != 0: sys.exit(f'STUCK continuing {o8}: {r2.stderr.strip()[:300]}') + for path, src in AFTER.get(o8, []): + open(os.path.join(wt, path), 'w').write(git('show', f'{rw(src)}:{path}').stdout); git('add', path) + notes.append(f'{os.path.basename(path)}: brought to the state the original merge {src} left it in, an edit that merge made outside its conflicts') + if o8 == '5efeae76': + git('update-index', '--cacheinfo', f"160000,{PIN[o8]},{GITLINK}") + notes.append('gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library\'s master as 84cc44c with an identical tree') + if notes: + git('commit', '-q', '--amend', '--no-edit', check=False) + amend_note('Replayed onto Mantra by docs/curated-to-mantra.md: ' + '; '.join(notes) + '.') + print(f"{o8} {'resolved' if notes else 'clean':8s} {subject[:80]}") + + +if __name__ == '__main__': + if len(sys.argv) < 4 or sys.argv[1] not in ('pick', 'check'): sys.exit(__doc__) + main() From 590230f2151601dcfea551590bb0f03009f97b39 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Thu, 10 Sep 2026 11:30:53 +0200 Subject: [PATCH 08/71] feat(groups): the group's own nostr identity, and the two ways a member could forge it A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`, where those are one value -- so every group in this app has had a nostr identity from the moment it had a key, and has never had a way to say anything about it. This adds the two statements that identity is made of: a kind:0 saying who the group is, and the four relay lists saying where it can be found. Both are signed by the group's quorum like everything else it says. Two sections on the group's detail screen, between the signing key and the library, and two editors behind them. Neither editor saves: what leaves them is a `FrostSigningSession`, and the profile changes on every member's device at once when enough members sign, or not at all. **A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.** `Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a `NostrEvent`: no member sent it, it never travelled the wire as itself, and the outbound pipeline re-authors rumors as their sender and would strip the group's signature off -- which is exactly the position `GroupSignedEvent` exists for. So `GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are readings rather than rows. Nothing new is stored: `FrostSigningManager.complete` already files every signed event before it applies one, so both readers had their data before this change and neither adds a table, a migration or a DAO method beyond the two existing kind-scoped queries. **Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust model.** The author has to be the room, the id has to be the hash of the fields beside it, and the signature has to verify -- checkable from the row alone, with no ceremony or key state to consult. A kind:0 that merely arrived in the room is not the group's profile; a relay list filed against the room by another group is not the group's relay list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's signature, a forged author and three kinds of rubbish in the signature field are all tested against the code that actually verifies rather than against a mock of it. **The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft did not.** Every kind the group signs needs an arm there or it falls through to `unsupported` and puts raw JSON in the transcript -- so a profile update would have appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot tell them apart: a completed signing session, where the author is the room by construction, and an arriving inner event, where it is whatever the sender wrote. A rumor carries an empty signature and any pubkey its sender likes, so the relay arm's original author-only check would have let one member write "the group signed its general relay list" into the room's transcript with no group involved. It now builds a `GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list passing through the room fails the author half and gets no line at all, which is right -- it is theirs, not the group's. **`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types. The profile and the relay lists are the same kind of statement and the transcript does the same thing with both -- a `RitualNotice`, answered and settled, because by the time the line exists a quorum has signed and there is nothing left to do about it. A type missing from that set renders as a chat bubble, silently, looking exactly like a member having said "The group is now called Translation collective"; that is why it is a set with two members rather than two checks. **Relay lists: four sets, and the two that were left out are the interesting part.** General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked (NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not here even though this app writes one for every person: a key package is a device's offer to be added to an MLS group, and a group has no device and joins nothing, so a group advertising where its key packages live would point at an address that is permanently empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason below. **A group signs and cannot decrypt, so every list is written in public tags.** NIP-51 puts a relay list's entries in the encrypted half by default, and a blocked list especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to the author's own key and there is no ECDH for a FROST threshold key here. That rules out 10012 entirely -- its list is only ever private -- and it means the group's blocked list is public where a person's client would keep it secret. Said in the code, and said on the Blocked tab where somebody is about to act on it. **The editor proposes every list that changed at once, which is the deliberate departure from the interface it copies.** Wisp publishes the tab in front of you, because a person signs for themselves and there is nothing to coordinate. Here each signature costs a quorum's attention, and four sessions for one sitting at one screen would ask for it four times over what is plainly one decision. `changedSets` is what keeps that honest, and it is the piece with a quiet failure on both sides: too eager and every visit re-signs four untouched lists, dating a decision the group did not make; too shy and an edit is dropped on a screen that said it had been sent. It compares in order, because a relay list is written in the order it is read back and a member who moved a relay to the front meant to; it counts a seeded first-time list as a change, so a group whose editor filled itself in will actually send it; and it counts an empty unagreed set as no change, so opening Blocked and pressing the button does not put a quorum's signature over silence. Nine cases in `EditGroupRelaysViewModelJvmTest`. **A relay that is neither read nor write is a state NIP-65 cannot express**, so it must not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read` for read-only and `write` for write-only, and has nothing for neither -- what neither would mean is that the relay is not in the list, and removing it is the row's own button. The toggles refuse to turn off the last marker and `template` drops such a relay as a second line of defence, because a bare tag written for it would advertise it as *both*, which is the opposite of what was asked. The round trip is tested for all three markers at once: the ordinary case is the dangerous one, since "both" is encoded by the third element being *absent*, so a reader that dropped it entirely would look correct for a both-ways relay and silently promote every read-only one. **`ephemeral.mantra.press` is what a first-time group is seeded with**, for General, Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish, finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to talk to somebody that nobody in the group chose. **A profile edit builds on the group's last one; a relay list does not.** `template` for the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead of being dropped by it. Using `createNew` there would compile, pass any test that only looked at the six fields on the form, and quietly wipe the rest every time somebody fixed a typo in the group's name. A relay list is the opposite: it is one list, replacing it is the whole point of signing a new one, and carrying a tag forward would make removal impossible. Both are tested for the behaviour that would be silent if wrong. **The name is written to both `name` and `display_name`.** They are the two fields readers pick a name out of and they disagree at their peril: a group whose `display_name` came from some other tool and whose `name` was edited here would keep answering to the old one in every client preferring `display_name` -- an edit that visibly does not take. One field on the form, both keys in the event. **Blank clears, and that is the only way to unsay something a group has signed.** `MetadataEvent`'s rule for these arguments is that an empty string removes the key and null leaves it alone, so the form sends what its fields hold. Said on the screen, because a member emptying a field is entitled to know whether it will be ignored. **Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather than a key it can sign as, so it has no nostr identity and never will; an empty section there would promise something that is not coming. Every other room shows the sections and gates only the two edit buttons on holding a share of the key -- the profile and the relay lists are worth reading whoever is looking, and only a share-holder can open a session about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry points and the subgroup one, since it walks the room's ceremonies looking for a share and asking three times would do the same walk three times for one answer. **"Never agreed" and "agreed empty" are different states and the summary says which.** A set the group has never signed reads "not set yet"; one it signed empty reads "no relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and `GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so the screen has a row per set either way. **The editor's working copy is seeded from the screen, not from the loader**, and that is a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an empty editor while the app worked fine, which is the shape of bug that survives review because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy` is idempotent and fills only missing keys, so the effect that calls it can re-run without throwing away typing. **`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since a group has no `Profile` row to hand it and the picture and the name were all it ever wanted from one. The two existing overloads delegate to it, so there is one avatar rather than a second one for groups. **What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete` applies signed events locally and puts nothing on the wire, for the reason its comment gives. So the profile and the lists are visible to members and to nobody else, and the relay lists say where the group's work *should* go without anything yet sending it there. Publishing would mean a `NostrEvent` row for an event no member authored, which is an architectural decision and belongs in its own change. Said at the top of both readers so the next reader does not assume otherwise. **And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds that a validly signed old one replayed by whoever kept a copy is a statement nobody can refuse. These five kinds are in the same position, so a member who joins after a profile is signed will not be handed it in their catch-up. Adding them is a decision with its own trade and is not made here. 48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape `\'`, so the aapt spelling would render the backslash. 1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`, 41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@4c0ed0c1ceeca5aba461fbca314821befc12c583 --- .../composeResources/values/strings.xml | 48 ++ .../compose/database/model/ChatMessage.kt | 126 ++++ .../model/intermdiate/LocalChatRoom.kt | 3 +- .../repository/DatabaseChatRepository.kt | 37 ++ .../mantra/compose/nostr/GroupNostrProfile.kt | 194 ++++++ .../mantra/compose/nostr/GroupRelayList.kt | 278 +++++++++ .../compose/repository/ChatRepository.kt | 28 + .../ui/composable/ChatRoomDetailScreen.kt | 297 ++++++++- .../composable/EditGroupNostrProfileScreen.kt | 423 +++++++++++++ .../ui/composable/EditGroupRelaysScreen.kt | 577 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 44 ++ .../routes/EditGroupNostrProfileRoute.kt | 10 + .../navigation/routes/EditGroupRelaysRoute.kt | 10 + .../composable/widgets/chat/ChatTranscript.kt | 19 + .../widgets/profile/ProfileAvatar.kt | 35 +- .../ui/view/model/ChatRoomDetailViewModel.kt | 12 +- .../model/EditGroupNostrProfileViewModel.kt | 161 +++++ .../ui/view/model/EditGroupRelaysViewModel.kt | 299 +++++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 37 ++ .../state/EditGroupNostrProfileUIState.kt | 34 ++ .../ui/view/state/EditGroupRelaysUIState.kt | 33 + .../compose/nostr/GroupNostrProfileTest.kt | 438 +++++++++++++ .../compose/nostr/GroupRelayListTest.kt | 398 ++++++++++++ .../GroupNostrProfileSectionJvmTest.kt | 278 +++++++++ .../model/EditGroupRelaysViewModelJvmTest.kt | 224 +++++++ 25 files changed, 4035 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f9ecbcd6..087a4478 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -398,4 +398,52 @@ Check the combined result and confirm it The group has its key — finish setting it up Open + Nostr profile + This group hasn't said anything about itself on Nostr yet. + Edit Nostr profile + Propose Nostr profile + What the group says about itself on Nostr + The group signs this, so it takes a quorum. An empty field unsays what the group had said. + This group has no shared key, so it cannot sign a profile. Run a shared key ceremony first. + Signed by the group on %1$s + No name on this profile + About + Picture url + Website + Nostr address + Lightning address + eg. Translation collective + eg. A group translating hard books into Sesotho + eg. https://example.com/group.png + eg. https://example.com + eg. group@example.com + eg. group@getalby.com + Couldn't ask the group to sign this profile. + Relays + Edit relays + Not set yet + No relays in this list + Where the group lives on Nostr + The group signs each list, so it takes a quorum. Only the lists you change are proposed. + This group has no shared key, so it cannot sign a relay list. Run a shared key ceremony first. + Relay url + eg. wss://relay.example.com + Add relay + That isn't a relay address. Relays start with wss:// and cannot be on this device. + That relay is already in this list. + Propose relays + Nothing has changed, so there is nothing to propose. + Couldn't ask the group to sign these relays. + Read + Write + General + Messages + Search + Blocked + Where the group publishes, and where a reader should look for it. + Where a message addressed to the group should be sent. + The relays a search of the group's work runs against. + Relays the group will not talk to. + A group signs and cannot decrypt, so every list here is public — including the blocked one, which most clients keep private. + Signed %1$s diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index f6db7723..4de3f97f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -20,6 +20,9 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents @@ -383,6 +386,47 @@ data class ChatMessage( */ const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" + /** + * The group signed what it says about itself on nostr. + * + * A system line rather than a bubble, for the reason every group-signed + * line is one: nobody said it, the group signed it. Whoever filled the + * form in proposed it and a quorum agreed, and the transcript of that + * happening is the signing session's own -- this is the line saying what + * the group now is, which is worth having in the room because a rename is + * the kind of thing members should not have to go looking for. + * + * Written only for a profile the *room* signed. A kind:0 authored by + * anybody else that reaches this room is a member's own profile + * travelling as a rumor, which is not the group saying anything. + */ + const val TYPE_GROUP_PROFILE_SIGNED = "groupProfileSigned" + + /** + * The group signed where it lives on nostr. + * + * One line per list, so a session that changed three of them writes three. + * They are not three accounts of one thing -- each names which list was + * agreed, which is the part a reader needs -- and `applyInnerEvent` is + * handed one event at a time with no way to know it was in a batch anyway. + */ + const val TYPE_GROUP_RELAYS_SIGNED = "groupRelaysSigned" + + /** + * Every line about the group's own nostr identity, for the one check the + * transcript dispatches on. + * + * The profile and the relay lists together, because they are the same kind + * of statement -- the group's account of itself, signed by the group -- and + * the transcript does the same thing with both. A type missing from here + * renders as a chat bubble, silently, looking exactly like somebody having + * said "The group is now called Translation collective". + */ + val GROUP_IDENTITY_TYPES = setOf( + TYPE_GROUP_PROFILE_SIGNED, + TYPE_GROUP_RELAYS_SIGNED, + ) + /** * Every subgroup line, for the one check the transcript dispatches on. * @@ -1345,6 +1389,88 @@ data class ChatMessage( ) } + // The group saying who it is on nostr. A room's id is the pubkey + // it signs as, so a group has an identity from the moment it has a + // key; this is the kind:0 describing it, signed by the group's own + // quorum like everything else it says. + // + // **Only when the room itself signed it.** This arm is reached both + // from a completed signing session, where the author is the room by + // construction, and from an arriving inner event, where any member + // could have sent a rumor of this kind -- and it cannot tell which. + // A member's own kind:0 travelling through a room is their profile, + // not the group's, and gets no line here. + // + // No row is written. The event is already on file as a + // `GroupSignedEvent` -- `FrostSigningManager.complete` records the + // batch before it applies it -- and `GroupNostrProfile` reads the + // group's profile straight off those. A `Profile` row is not an + // option anyway: it hangs off `NostrEvent` by foreign key, and a + // group-signed event is not one. + MetadataEvent.KIND -> { + if (!event.pubKey.equals(groupId, ignoreCase = true)) return null + + val profile = GroupNostrProfile.of( + signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId), + chatRoomId = groupId, + ) ?: return null + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_PROFILE_SIGNED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = profile.name()?.let { "The group is now called $it" } + ?: "The group signed a new profile for itself" + ) + } + + // The group saying where it lives on nostr: which relays carry + // its work, take its messages, answer a search of it, and which it + // will not talk to. + // + // **Verified, not just attributed.** This arm is reached from a + // completed signing session, where the author is the room by + // construction, and from an arriving inner event, where it is + // whatever the sender wrote -- and it cannot tell which. A rumor + // carries an empty signature and any pubkey its sender likes, so an + // author check alone would let one member put "the group signed its + // general relay list" in the transcript. `verifies` is what makes + // the line mean what it says: the room's key over these tags. + // + // A member's own relay list travelling through the room fails the + // author half and gets no line, which is right -- it is their list, + // not the group's. + // + // No row is written, and nothing is parsed beyond the check. The + // events are already on file as `GroupSignedEvent` and + // `GroupRelayList` reads the lists straight off those; this arm + // exists so a signed relay list is a line in the transcript rather + // than raw JSON in a bubble. + in GroupRelaySet.KINDS -> { + val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) + if (!signed.verifies()) return null + + val set = GroupRelaySet.entries.firstOrNull { it.kind == event.kind } + ?: return null + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_RELAYS_SIGNED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group signed its ${set.name.lowercase()} relay list" + ) + } + else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt index 9ca34fd7..1d0a7083 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt @@ -117,7 +117,8 @@ data class LocalChatRoom( lastChatMessage.messageType in ChatMessage.FROST_TYPES || lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES || lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES || - lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES + lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES || + lastChatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES ) { val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES || lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index b931ad84..25a629df 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -9,6 +9,9 @@ import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -25,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -120,6 +124,39 @@ class DatabaseChatRepository( null } + override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = try { + GroupNostrProfile.newestAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, MetadataEvent.KIND), + chatRoomId = chatRoomId, + ) + } catch (e: Throwable) { + // Reading one parses content off the wire and checks a signature, and a + // room that shows no profile is wrong and survivable where one that will + // not open is neither -- the same trade the readings above make. + logger.e("Error reading the nostr profile of $chatRoomId", e) + null + } + + override suspend fun groupRelayLists(chatRoomId: String): List = try { + // One query per set rather than one for the room: the four kinds are + // indexed and a room's whole signed history is every artifact, chunk and + // translation it has ever agreed. + GroupRelaySet.entries.map { set -> + GroupRelayList.newestAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, set.kind), + chatRoomId = chatRoomId, + set = set, + ) + } + } catch (e: Throwable) { + logger.e("Error reading the relay lists of $chatRoomId", e) + // Four unsigned, empty sets: the screen draws "not set yet" rather than + // failing to draw, which is the trade every reading above makes. + GroupRelayList.allAmong(emptyList(), chatRoomId) + } + override suspend fun canSign(chatRoomId: String): Boolean = try { FrostSigningManager.canSign(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt new file mode 100644 index 00000000..536b21f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt @@ -0,0 +1,194 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.time.Instant + +/** + * The kind:0 a group signed about itself, as the group signed it. + * + * A Marmot room's id *is* the nostr pubkey it signs as -- see + * `docs/shared-key-derivation.md`, where those are one value -- so the group has + * an identity on nostr whether or not it has ever said anything about it. This is + * what it says: a name, a picture, a line about what the group is for, authored by + * the group rather than by whichever member typed it. + * + * ### Why this reads off [GroupSignedEvent] and not off `Profile` + * + * A `Profile` row hangs off a `NostrEvent` by foreign key, and a group-signed + * event is not a `NostrEvent`: no member sent it, it never travelled on the wire + * as itself, and the outbound pipeline would re-author it as its sender and strip + * the group's signature off. See `GroupSignedEvent`, which exists for exactly the + * events in that position. So the group's profile lives where the group's other + * signed statements live, and this is the reading of it. + * + * The consequence worth stating: **nothing here has reached a relay.** A member's + * kind:0 is published and is how the rest of nostr learns their name; a group's is + * signed and kept, and every device that followed the signing session -- or was + * handed the event afterwards -- holds it. Publishing it would mean a `NostrEvent` + * row for an event no member authored, which is a decision this does not make. + * + * ### What a reading has to earn + * + * [GroupSignedEvent.verifies] and nothing less: the author has to be the room, the + * id has to be the hash of the fields beside it, and the signature has to verify. + * That is what makes this the *group's* profile rather than a kind:0 that happened + * to be filed against the room -- and it is checkable from the row alone, with no + * ceremony or key state to consult first. + * + * A room never derived from its group's key signs as the bare threshold key rather + * than as its own id, so it has no profile *for its own pubkey* and gets none + * here. That is the derivation's limit rather than this reader's, and it is the + * same limit `GroupSignedEvent.verifies` documents. + */ +data class GroupNostrProfile( + /** The group's statement, whole, with the signature that makes it one. */ + val signedEvent: GroupSignedEvent, + /** Its content parsed: the fields nostr clients read a profile out of. */ + val metadata: UserMetadata, +) { + /** The group's nostr identity, which for a derived room is also its id. */ + val publicKey: HexKey get() = signedEvent.publicKey + + /** When the group signed it, which is what decides the newest of two. */ + val signedAt: Instant get() = signedEvent.createdAt + + /** + * What to call the group, or null when it has published no name. + * + * `display_name` before `name`, which is what `UserMetadata.anyName` does and + * what every other nostr client does. Null rather than the pubkey: a caller + * showing this beside the pubkey would otherwise show it twice. + */ + fun name(): String? = metadata.anyName()?.takeIf { it.isNotBlank() } + + fun about(): String? = metadata.about?.takeIf { it.isNotBlank() } + + fun picture(): String? = metadata.picture?.takeIf { it.isNotBlank() } + + fun banner(): String? = metadata.banner?.takeIf { it.isNotBlank() } + + fun website(): String? = metadata.website?.takeIf { it.isNotBlank() } + + fun nip05(): String? = metadata.nip05?.takeIf { it.isNotBlank() } + + fun lud16(): String? = metadata.lud16?.takeIf { it.isNotBlank() } + + /** The event as the group signed it, for [MetadataEvent.updateFromPast]. */ + fun asMetadataEvent(): MetadataEvent = signedEvent.toEvent().let { event -> + MetadataEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + } + + companion object { + /** + * The reading of one signed event, or null when it is not one of these. + * + * Three ways to be null and they are all the same refusal: the wrong kind, + * a signature that does not check out as [chatRoomId]'s, or content that + * will not parse as a profile. A caller gets a profile the group really + * signed or gets nothing. + */ + fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupNostrProfile? { + if (signedEvent.kind != MetadataEvent.KIND) return null + if (!signedEvent.verifies()) return null + + // An empty kind:0 parses to an empty profile rather than to null -- + // wiping a profile is a thing groups are entitled to do, and quartz + // reads it as the blank one it is. Null here is a parse failure. + val metadata = MetadataEvent( + id = signedEvent.id, + pubKey = signedEvent.publicKey, + createdAt = signedEvent.createdAt.epochSeconds, + tags = signedEvent.tags, + content = signedEvent.content, + sig = signedEvent.signature, + ).contactMetaData() ?: return null + + return GroupNostrProfile(signedEvent = signedEvent, metadata = metadata) + } + + /** + * The newest profile [chatRoomId] signed among [signedEvents], or null if + * it signed none. + * + * Newest by the timestamp the group signed at, with the id breaking a tie, + * because kind:0 is replaceable: a group that edits its profile signs a + * second one and the second is the answer. Two devices reading the same + * events in whatever order the queries hand them over have to agree on + * which, and a tie on the second is not rare enough to leave to luck. + * + * Takes the events rather than fetching them so the same question can be + * asked of a query's result or of a flow's emission. + */ + fun newestAmong( + signedEvents: List, + chatRoomId: String, + ): GroupNostrProfile? = + signedEvents + .asSequence() + .mapNotNull { of(it, chatRoomId) } + .maxWithOrNull( + compareBy({ it.signedAt }, { it.signedEvent.id }) + ) + + /** + * The event to ask the group to sign for a profile with these fields. + * + * Built from [latest] where the group has one, so a field this app does not + * offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives + * an edit instead of being dropped by it. That is `updateFromPast`'s whole + * job, and reaching for `createNew` on a group that already has a profile + * would quietly wipe every such field. + * + * Blank means delete, which is `MetadataEvent`'s own rule for these + * arguments: an empty string removes the key and null leaves it alone. The + * screen sends what its fields hold, so clearing one clears it in the + * profile -- which is the only way to *un*-say something a group has said. + * + * [name] is written to both `name` and `display_name`, which are the two + * fields readers pick a name out of and which disagree at their peril. A + * group whose `display_name` was set by some other tool and whose `name` was + * edited here would keep answering to the old one in every client that + * prefers `display_name` -- an edit that visibly does not take. One field on + * the form, both keys in the event. + */ + fun template( + latest: GroupNostrProfile?, + name: String, + about: String, + picture: String, + website: String, + nip05: String, + lud16: String, + ): EventTemplate = latest?.let { + MetadataEvent.updateFromPast( + latest = it.asMetadataEvent(), + name = name, + displayName = name, + about = about, + picture = picture, + website = website, + nip05 = nip05, + lnAddress = lud16, + ) + } ?: MetadataEvent.createNew( + name = name, + displayName = name, + about = about, + picture = picture, + website = website, + nip05 = nip05, + lnAddress = lud16, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt new file mode 100644 index 00000000..82e31bef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt @@ -0,0 +1,278 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import kotlin.time.Instant +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag as Nip51RelayTag +import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag as Nip17RelayTag + +/** + * Where a group lives on nostr, as the group itself has said. + * + * A Marmot room's id is the pubkey it signs as, so the group is an author like any + * other and the same four relay lists apply to it: where it publishes and is read + * from, where its messages arrive, where it can be searched for, and what it will + * not talk to. Each is a replaceable event of its own kind, so each is agreed -- + * and can be missing -- separately. + * + * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the + * reasons `GroupNostrProfile` gives at length: a group-signed event is not a + * `NostrEvent`, and only an event the room itself authored is the group speaking. + * + * ### Nothing here has reached a relay either + * + * The lists say where the group's work *should* go; sending it there is a separate + * job this does not do. See `GroupNostrProfile`, which carries the same caveat for + * the same reason. + * + * ### Public tags only, and not by preference + * + * NIP-51 puts a relay list's entries in the encrypted half by default -- a blocked + * list especially, since who you refuse to talk to is nobody's business. A group + * cannot use it. The encryption is NIP-44 to the author's own key, and there is no + * ECDH for a FROST threshold key here: the group can sign and cannot decrypt. So + * every list is written in public tags, which is a real difference from what a + * person's client would write and is worth knowing before blocking anything. + */ +data class GroupRelayList( + val set: GroupRelaySet, + /** + * The group's statement, or null for a set it has never agreed. + * + * Null and an empty [relays] are different states: never said, versus said and + * said nothing. A group that signs an empty list has withdrawn the one it had. + */ + val signedEvent: GroupSignedEvent?, + val relays: List, +) { + /** When the group signed it, or null for a set it has never agreed. */ + val signedAt: Instant? get() = signedEvent?.createdAt + + /** Whether the group has ever put its key to this set. */ + val isAgreed: Boolean get() = signedEvent != null + + companion object { + /** + * The reading of one signed event as [set], or null when it is not one. + * + * The same three refusals as `GroupNostrProfile.of`: the wrong kind, a + * signature that is not [chatRoomId]'s, or tags that carry no relay. + */ + fun of( + signedEvent: GroupSignedEvent, + chatRoomId: String, + set: GroupRelaySet, + ): GroupRelayList? { + if (signedEvent.kind != set.kind) return null + if (!signedEvent.verifies()) return null + + return GroupRelayList( + set = set, + signedEvent = signedEvent, + relays = set.parse(signedEvent.tags), + ) + } + + /** + * The newest list [chatRoomId] signed for [set] among [signedEvents]. + * + * Never null: a set the group has never agreed reads back as an unsigned, + * empty list rather than as an absence, so a caller always has a row per + * set to show and [isAgreed] is what separates the two. + * + * Newest by the group's own timestamp with the id breaking a tie, for the + * reason `GroupNostrProfile.newestAmong` gives: these are replaceable, and + * two devices reading the same events in different orders have to agree. + */ + fun newestAmong( + signedEvents: List, + chatRoomId: String, + set: GroupRelaySet, + ): GroupRelayList = + signedEvents + .asSequence() + .mapNotNull { of(it, chatRoomId, set) } + .maxWithOrNull(compareBy({ it.signedAt }, { it.signedEvent?.id })) + ?: GroupRelayList(set = set, signedEvent = null, relays = emptyList()) + + /** Every set, in the order the editor shows them. */ + fun allAmong( + signedEvents: List, + chatRoomId: String, + ): List = + GroupRelaySet.entries.map { newestAmong(signedEvents, chatRoomId, it) } + } +} + +/** + * One relay in one of a group's lists. + * + * [read] and [write] mean something only in [GroupRelaySet.General], which is the + * one list NIP-65 gives markers to; everywhere else a relay is simply in the list + * and both are true. They are kept on the shared type rather than split into two + * so that the editor has one row shape and one add path. + * + * **Neither is not a state.** NIP-65 writes a bare `r` tag for both, `read` for + * read-only and `write` for write-only, and has nothing at all for a relay that is + * neither -- a relay you will not read from and will not write to is one you have + * removed. The editor enforces that rather than this dropping it silently. + */ +data class GroupRelay( + val url: NormalizedRelayUrl, + val read: Boolean = true, + val write: Boolean = true, +) { + /** The host, for a list that is read rather than copied. */ + fun displayUrl(): String = url.displayUrl() +} + +/** + * The relay lists a group keeps, and what each one is for. + * + * The four a person's client keeps, because a group is an author like any other and + * these are the four questions the network asks about one. + * + * **Key packages (MIP-00, kind 10051) are deliberately not here**, though this app + * writes one for every person. A key package is a device's offer to be added to an + * MLS group, and a group has no device and joins nothing -- so a group advertising + * where its key packages live would be pointing at a place that will always be + * empty, which is worse than saying nothing. See `MarmotKeyPackage`, which is keyed + * by member. + * + * **Relay feeds (kind 10012) are not here either**, for the harder reason: the list + * lives in the encrypted half and a threshold key cannot decrypt. See + * [GroupRelayList]'s note on public tags. + */ +enum class GroupRelaySet( + val kind: Kind, + /** Whether NIP-65's read and write markers apply, which is General alone. */ + val hasReadWriteMarkers: Boolean = false, +) { + /** + * NIP-65: where the group publishes and where a reader should look for it. + * + * The one that matters most and the only one with markers. Everything the group + * signs is addressed to whoever wants to read it, and this is the answer to + * where. + */ + General(AdvertisedRelayListEvent.KIND, hasReadWriteMarkers = true), + + /** NIP-17: where a message addressed to the group's key should be sent. */ + Messages(ChatMessageRelayListEvent.KIND), + + /** NIP-50: the relays the group would have a search of its work run against. */ + Search(SearchRelayListEvent.KIND), + + /** NIP-51: relays the group will not talk to, whatever else says otherwise. */ + Blocked(BlockedRelayListEvent.KIND), + ; + + /** + * What a group with nothing agreed starts the editor at. + * + * The app's own relay, which is where everything else this build publishes and + * reads already goes -- see `Relays.ephemeral`. A group seeded with nothing + * would be a group whose first proposal is an empty list, which says less than + * having never said anything at all. + * + * Blocked is the exception and starts empty on purpose: a default there is a + * refusal to talk to somebody that nobody in the group chose. + */ + fun defaults(): List = when (this) { + Blocked -> emptyList() + else -> listOf(GroupRelay(Relays.ephemeral)) + } + + /** The relays out of a signed event's tags, in the order the group wrote them. */ + fun parse(tags: Array>): List = when (this) { + General -> tags.mapNotNull(AdvertisedRelayInfo::parse).map { + GroupRelay( + url = it.relayUrl, + read = it.type.isRead(), + write = it.type.isWrite(), + ) + } + // Both nip51 lists and NIP-17's use a "relay" tag, and the two RelayTag + // classes that parse it are different classes in different packages. Named + // apart at the import so a reader can see which NIP each line is following. + Messages -> tags.mapNotNull(Nip17RelayTag::parse).map { GroupRelay(it) } + Search, Blocked -> tags.mapNotNull(Nip51RelayTag::parse).map { GroupRelay(it) } + }.distinctBy { it.url } + + /** + * The event to ask the group to sign for this set. + * + * Built from scratch rather than from the group's last one, which is the + * opposite of what `GroupNostrProfile.template` does and for a reason: a + * profile is a bag of independent fields where an unknown one is worth keeping, + * and a relay list is one list where the whole point of signing a new one is to + * replace it. Carrying a tag forward here would make a removal impossible. + * + * A relay that is neither read nor write is dropped rather than written, since + * NIP-65 cannot express one -- the editor does not allow the state, and this is + * the second line of that defence rather than the first. + */ + fun template(relays: List): EventTemplate<*> { + val tags = when (this) { + General -> relays.mapNotNull { relay -> + val type = when { + relay.read && relay.write -> AdvertisedRelayType.BOTH + relay.read -> AdvertisedRelayType.READ + relay.write -> AdvertisedRelayType.WRITE + else -> return@mapNotNull null + } + AdvertisedRelayInfo.assemble(relay.url, type) + } + Messages -> relays.map { Nip17RelayTag.assemble(it.url) } + Search, Blocked -> relays.map { Nip51RelayTag.assemble(it.url) } + } + + // Empty content throughout. For General and Messages that is all the kind + // has; for the two nip51 lists it is where the encrypted half would go, and + // a group has no encrypted half. See [GroupRelayList]. + return EventTemplate( + createdAt = kotlin.time.Clock.System.now().epochSeconds, + kind = kind, + tags = tags.toTypedArray(), + content = "", + ) + } + + companion object { + /** + * The kinds a group's relay lists are written as. + * + * A set rather than a walk of [entries], for `ChatMessage.applyInnerEvent`, + * whose dispatch is one `when` over an event's kind -- and a `when` branch + * has to be a constant expression rather than a predicate over an enum. + */ + val KINDS: Set = entries.map { it.kind }.toSet() + + /** + * [url] as a relay a group could actually be told to use, or null. + * + * Structural only, and deliberately narrow: `wss://` because a group's + * signed list is read by strangers over the open network and `ws://` would + * ask them to fetch it in the clear, and no loopback because a relay only + * this device can reach is not something to put a quorum's signature on. + * Wisp's `RelayConfig.isValidUrl` refuses the same three things. + */ + fun relayUrlOrNull(url: String): NormalizedRelayUrl? { + val trimmed = url.trim() + if (!trimmed.startsWith("wss://", ignoreCase = true)) return null + + return RelayUrlNormalizer.normalizeOrNull(trimmed)?.takeUnless { it.isLocalHost() } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 8fbfa95e..71fd8fea 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -9,6 +9,8 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -79,6 +81,27 @@ interface ChatRepository { */ suspend fun parentOf(chatRoomId: String): HexKey? + /** + * The kind:0 this group signed about itself, or null while it has signed + * none. + * + * The group's identity on nostr is the room's own id, so it has one to + * describe whether or not it ever has. Read off the group's signed events and + * verified there -- see `GroupNostrProfile` -- so a room shows the profile its + * own key signed or shows that it has none. + */ + suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? + + /** + * Where this group has said it lives on nostr: one entry per relay set, always + * all of them. + * + * A set the group has never agreed comes back unsigned and empty rather than + * missing, because "never said" is a state the screen has to show and a gap in + * a list is not one. See `GroupRelayList.isAgreed`. + */ + suspend fun groupRelayLists(chatRoomId: String): List + /** * Whether this device holds a share of the group's key, and so could take * part in signing for it. @@ -260,6 +283,11 @@ interface ChatRepository { override suspend fun parentOf(chatRoomId: String): HexKey? = null + override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = null + + override suspend fun groupRelayLists(chatRoomId: String): List = + GroupRelayList.allAmong(emptyList(), chatRoomId) + override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun refuseSubgroup( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index e816e78b..6dce2aef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.PersonAdd @@ -69,6 +71,8 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.extensions.toFormattedTimeAndDateString @@ -81,11 +85,26 @@ import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members +import mantra.composeapp.generated.resources.nostr_profile +import mantra.composeapp.generated.resources.relays +import mantra.composeapp.generated.resources.edit_relays +import mantra.composeapp.generated.resources.not_set_yet +import mantra.composeapp.generated.resources.no_relays_in_this_list +import mantra.composeapp.generated.resources.relay_set_general +import mantra.composeapp.generated.resources.relay_set_messages +import mantra.composeapp.generated.resources.relay_set_search +import mantra.composeapp.generated.resources.relay_set_blocked +import mantra.composeapp.generated.resources.no_name_on_this_profile +import mantra.composeapp.generated.resources.edit_nostr_profile +import mantra.composeapp.generated.resources.signed_by_the_group_on +import mantra.composeapp.generated.resources.this_group_has_not_said_anything_about_itself import mantra.composeapp.generated.resources.parent_group import mantra.composeapp.generated.resources.a_subgroup import mantra.composeapp.generated.resources.created_you_are_not_a_member @@ -97,6 +116,9 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.add_artifact_to_library @@ -314,6 +336,123 @@ fun ChatRoomDetailScreen( HorizontalDivider() } + // Under the room's identity and above its work, because + // that is what it is: what the rest of nostr reads for + // the key the signing key row names. The room's subject + // and description above are this device's names for it; + // this is the group's own, signed by the group. + // + // Marmot rooms only, and absent rather than empty in a + // NIP-17 one. A NIP-17 room's id is not a key it can + // sign as, so it has no nostr identity at all -- and a + // section saying it has not described one yet would + // promise something that is never coming. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { + item { + Text( + text = stringResource(Res.string.nostr_profile), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + chatRoomDetailUIState.groupNostrProfile?.let { groupNostrProfile -> + GroupNostrProfileCard( + groupNostrProfile = groupNostrProfile, + publicKey = chatRoomId + ) + } ?: Text( + stringResource( + Res.string.this_group_has_not_said_anything_about_itself + ) + ) + } + + // The profile is worth reading either way; proposing + // one is a signature by the group, and + // `proposeSigningBatch` throws for a device holding + // no share of the key. Hidden rather than disabled, + // the way the subgroup entry is. + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + EditGroupNostrProfileRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Badge, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.edit_nostr_profile)) + } + } + } + + // Directly under the profile, because between them + // they are the whole of the group's account of + // itself on nostr: who it says it is, and where it + // says it can be found. Same gate, same reasons. + item { + Text( + text = stringResource(Res.string.relays), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + GroupRelayListsCard( + relayLists = chatRoomDetailUIState.groupRelayLists + ) + } + + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + EditGroupRelaysRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Dns, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.edit_relays)) + } + } + } + + item { + HorizontalDivider() + } + } + item { // Artifacts Text( @@ -1056,6 +1195,135 @@ internal fun GroupKeyStateSheetContent( * signed event, which is not grouped -- so the display can be shaped for reading * without a paste losing the value. */ +/** + * What the group says about itself on nostr, as the group signed it. + * + * A preview rather than a profile screen: the name, the picture, the nostr address + * and the first lines of the group's own account of itself, which is what a member + * opening the group's details came here to check. The whole thing is one signed + * event and the proposal list is where it can be read out in full. + * + * **The picture and the name come from the metadata; the tint comes from the key.** + * [publicKey] is the room's id, which is the pubkey the group signs as, so a group + * with no picture yet still gets the colour it has everywhere else in the app. + * + * The signing date is here because a group's profile is standing state with no + * other clue to its age -- and because the alternative reading, that this arrived + * from a relay a moment ago, is exactly what has not happened. Nothing on this card + * has been published: see `GroupNostrProfile`. + */ +@Composable +private fun GroupNostrProfileCard( + groupNostrProfile: GroupNostrProfile, + publicKey: HexKey +) { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = publicKey, + picture = groupNostrProfile.picture(), + name = groupNostrProfile.name() + ) + }, + headlineContent = { + Text( + // Named rather than falling back to the pubkey: the signing key + // row above already shows that, and showing it twice would say + // the group has a name when it has not. + text = groupNostrProfile.name() + ?: stringResource(Res.string.no_name_on_this_profile), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + groupNostrProfile.nip05()?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + groupNostrProfile.about()?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.signed_by_the_group_on, + groupNostrProfile.signedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +/** + * Where the group has said it lives on nostr, one line per relay list. + * + * One card holding four rows rather than four cards: they are read together -- the + * question is whether the group is reachable, not what any single list says -- and + * four cards of one line each on a screen this long is four dividers to scroll past + * for the same four facts. + * + * **A list the group has never agreed reads "not set yet", and an agreed empty one + * reads "no relays".** Those are different things: one is a group that has not got + * round to it, and the other is a group that decided. Collapsing them would hide a + * deliberate withdrawal behind an oversight. + */ +@Composable +private fun GroupRelayListsCard(relayLists: List) { + Card(modifier = Modifier.fillMaxWidth()) { + relayLists.forEach { list -> + ListItem( + leadingContent = { + Icon(Icons.Default.Dns, contentDescription = Decorative) + }, + headlineContent = { + Text(text = stringResource(list.set.summaryLabel())) + }, + supportingContent = { + Text( + text = when { + !list.isAgreed -> stringResource(Res.string.not_set_yet) + list.relays.isEmpty() -> + stringResource(Res.string.no_relays_in_this_list) + // Hosts rather than URLs: every one of them starts + // `wss://`, so the scheme is the part that never + // distinguishes two entries. + else -> list.relays.joinToString { it.displayUrl() } + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } +} + +/** The name of a relay list, for the summary on the group's screen. */ +private fun GroupRelaySet.summaryLabel() = when (this) { + GroupRelaySet.General -> Res.string.relay_set_general + GroupRelaySet.Messages -> Res.string.relay_set_messages + GroupRelaySet.Search -> Res.string.relay_set_search + GroupRelaySet.Blocked -> Res.string.relay_set_blocked +} + /** * One subgroup, as the parent's record and this device's rooms together have it. * @@ -1238,7 +1506,10 @@ private fun ChatRoomMessagingScreenPreview() { subject = "Message title", description = "Description of a chat room so that members now why they are here.", initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = null + // A Marmot room, so the nostr profile section renders + // at all -- it is hidden in a NIP-17 one, which has no + // key to have an identity on nostr. + mlsGroupState = "state" ), localParticipants = listOf( LocalParticipant( @@ -1266,7 +1537,29 @@ private fun ChatRoomMessagingScreenPreview() { derivationPath = "m/9420/0/0", announcedBy = "d".repeat(64), announcedAt = Instant.fromEpochSeconds(1_700_000_000) - ) + ), + // A group that has described itself, so the section renders + // with something in it rather than only in its empty state. + // Built directly rather than read out of a signed event: the + // reading checks a signature, and there is no real one here. + groupNostrProfile = GroupNostrProfile( + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = "publicKey", + publicKey = "publicKey", + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + metadata = UserMetadata().apply { + displayName = "Translation collective" + about = "A group translating hard books into Sesotho." + nip05 = "group@example.com" + } + ), + canEditNostrProfile = true ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt new file mode 100644 index 00000000..33e8d287 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt @@ -0,0 +1,423 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Title +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.EditGroupNostrProfileViewModel +import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.about +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_profile +import mantra.composeapp.generated.resources.edit_nostr_profile +import mantra.composeapp.generated.resources.eg_a_group_translating_hard_books +import mantra.composeapp.generated.resources.eg_group_example_com +import mantra.composeapp.generated.resources.eg_group_getalby_com +import mantra.composeapp.generated.resources.eg_https_example_com +import mantra.composeapp.generated.resources.eg_https_example_com_group_png +import mantra.composeapp.generated.resources.eg_translation_collective +import mantra.composeapp.generated.resources.lightning_address +import mantra.composeapp.generated.resources.name +import mantra.composeapp.generated.resources.nostr_address +import mantra.composeapp.generated.resources.picture_url +import mantra.composeapp.generated.resources.propose_nostr_profile +import mantra.composeapp.generated.resources.the_group_signs_its_profile_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_profile +import mantra.composeapp.generated.resources.website +import mantra.composeapp.generated.resources.what_the_group_says_about_itself_on_nostr +import org.jetbrains.compose.resources.stringResource + +/** + * The form for what a group says about itself on nostr. + * + * A group's kind:0 is signed by the group, so pressing the button proposes rather + * than saves: the form goes out as one event for the room's quorum to put its key + * to, and the screen hands over to the signing session it opened. Nothing about the + * profile has changed by the time this screen closes. + * + * The fields start at what the group already says, so an edit is an edit. And they + * are sent as they stand -- **an emptied field clears what the group had said**, + * which is the only way to unsay something a group has signed. Fields this form + * does not offer are carried across untouched; see `GroupNostrProfile.template`. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun EditGroupNostrProfileScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState = + EditGroupNostrProfileUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val editGroupNostrProfileViewModel: EditGroupNostrProfileViewModel = viewModel( + factory = EditGroupNostrProfileViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in the click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so the message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_profile) + + ScreenStateTransition(editGroupNostrProfileViewModel.editGroupNostrProfileUIState) { uiState -> + when (val editGroupNostrProfileUIState = uiState) { + is EditGroupNostrProfileUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState( + message = editGroupNostrProfileUIState.message, + onRetry = null + ) + } + + is EditGroupNostrProfileUIState.Loaded -> { + val profile = editGroupNostrProfileUIState.groupNostrProfile + + // Seeded from the profile the group already signed, so the form is + // an edit of it. `rememberTextFieldState` saves what is typed, which + // is what survives a rotation with the edits intact. + val nameFieldState = rememberTextFieldState(profile?.name() ?: "") + val aboutFieldState = rememberTextFieldState(profile?.about() ?: "") + val pictureFieldState = rememberTextFieldState(profile?.picture() ?: "") + val websiteFieldState = rememberTextFieldState(profile?.website() ?: "") + val nip05FieldState = rememberTextFieldState(profile?.nip05() ?: "") + val lud16FieldState = rememberTextFieldState(profile?.lud16() ?: "") + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + val canSign = editGroupNostrProfileUIState.canSign + + // imePadding: this screen is nothing but text fields, and without it + // the software keyboard covers whichever one is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + editGroupNostrProfileUIState.localChatRoom + .RenderChatRoomTitleText() + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + editGroupNostrProfileViewModel.proposeNostrProfile( + localChatRoom = + editGroupNostrProfileUIState.localChatRoom, + groupNostrProfile = profile, + nameField = nameFieldState, + aboutField = aboutFieldState, + pictureField = pictureFieldState, + websiteField = websiteFieldState, + nip05Field = nip05FieldState, + lud16Field = lud16FieldState, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. The profile has not + // changed yet -- it changes when a + // quorum signs -- so landing on a + // group still showing the old one + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // Stay on the form with what was typed + // still in it. The proposal is what + // failed, and every word of it is worth + // keeping for the retry. + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon( + Icons.Default.Draw, + contentDescription = "Propose nostr profile" + ) + Text(stringResource(Res.string.propose_nostr_profile)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.what_the_group_says_about_itself_on_nostr)) + + Text( + text = stringResource( + Res.string.the_group_signs_its_profile_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_a_profile + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + NostrProfileField( + state = nameFieldState, + icon = Icons.Default.Title, + iconDescription = "Name of the group", + label = stringResource(Res.string.name), + placeholder = stringResource(Res.string.eg_translation_collective) + ) + + NostrProfileField( + state = aboutFieldState, + icon = Icons.AutoMirrored.Filled.Notes, + iconDescription = "What the group is for", + label = stringResource(Res.string.about), + placeholder = stringResource( + Res.string.eg_a_group_translating_hard_books + ), + // The one field with prose in it, and the only one a + // single line would truncate while it was being typed. + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 3) + ) + + NostrProfileField( + state = pictureFieldState, + icon = Icons.Default.Image, + iconDescription = "Picture of the group", + label = stringResource(Res.string.picture_url), + placeholder = stringResource(Res.string.eg_https_example_com_group_png) + ) + + NostrProfileField( + state = websiteFieldState, + icon = Icons.Default.Link, + iconDescription = "Website of the group", + label = stringResource(Res.string.website), + placeholder = stringResource(Res.string.eg_https_example_com) + ) + + NostrProfileField( + state = nip05FieldState, + icon = Icons.Default.AlternateEmail, + iconDescription = "Nostr address of the group", + label = stringResource(Res.string.nostr_address), + placeholder = stringResource(Res.string.eg_group_example_com) + ) + + NostrProfileField( + state = lud16FieldState, + icon = Icons.Default.Bolt, + iconDescription = "Lightning address of the group", + label = stringResource(Res.string.lightning_address), + placeholder = stringResource(Res.string.eg_group_getalby_com) + ) + } + } + } + + EditGroupNostrProfileUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.edit_nostr_profile), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialEditGroupNostrProfileUIState == EditGroupNostrProfileUIState.Loading) { + editGroupNostrProfileViewModel.initiateEditGroupNostrProfile() + } + } +} + +/** + * One field of the profile, in the shape the app's other forms use. + * + * Six of these on one screen is what makes it worth a function: the borderless + * outlined field over the bottom bar's tint is four lines of colours apiece, and + * six copies of that is where a divergence hides. + */ +@Composable +private fun NostrProfileField( + state: TextFieldState, + icon: ImageVector, + iconDescription: String, + label: String, + placeholder: String, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine +) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), + state = state, + lineLimits = lineLimits, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(icon, contentDescription = iconDescription) + }, + label = { + Text(text = label) + }, + placeholder = { + Text(text = placeholder) + } + ) +} + +@ConformancePreviews +@Composable +private fun EditGroupNostrProfileScreenPreview() { + MantraTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + EditGroupNostrProfileScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialEditGroupNostrProfileUIState = EditGroupNostrProfileUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation collective", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // A group that can sign, so the form renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt new file mode 100644 index 00000000..6a0003f3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt @@ -0,0 +1,577 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.EditGroupRelaysViewModel +import press.mantra.compose.ui.view.state.EditGroupRelaysUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.add_relay +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_these_relays +import mantra.composeapp.generated.resources.edit_relays +import mantra.composeapp.generated.resources.eg_wss_relay_example_com +import mantra.composeapp.generated.resources.no_relays_in_this_list +import mantra.composeapp.generated.resources.nothing_has_changed_to_propose +import mantra.composeapp.generated.resources.propose_relays +import mantra.composeapp.generated.resources.relay_read +import mantra.composeapp.generated.resources.relay_set_blocked +import mantra.composeapp.generated.resources.relay_set_blocked_purpose +import mantra.composeapp.generated.resources.relay_set_general +import mantra.composeapp.generated.resources.relay_set_general_purpose +import mantra.composeapp.generated.resources.relay_set_messages +import mantra.composeapp.generated.resources.relay_set_messages_purpose +import mantra.composeapp.generated.resources.relay_set_search +import mantra.composeapp.generated.resources.relay_set_search_purpose +import mantra.composeapp.generated.resources.relay_url +import mantra.composeapp.generated.resources.relay_write +import mantra.composeapp.generated.resources.relays_are_public_a_group_cannot_encrypt +import mantra.composeapp.generated.resources.that_is_not_a_relay_address +import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list +import mantra.composeapp.generated.resources.the_group_signs_each_list_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_relays +import mantra.composeapp.generated.resources.where_the_group_lives_on_nostr +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** + * The four relay lists a group keeps, edited as one sitting. + * + * A tab per list, a field to add one, a row per relay with a way to drop it, and -- + * in the one list NIP-65 gives markers to -- a read and a write chip. The shape is + * borrowed from Wisp's relay screen, which is the interface this app was asked to + * match, with one deliberate departure. + * + * **The button proposes rather than publishes.** Wisp signs with the user's own key + * and a tab's publish button is done the moment it is pressed. A group signs with a + * quorum, so what leaves this screen is a proposal, and the screen hands over to + * the session it opened. Nothing has changed until enough members sign. + * + * And it proposes **every list that changed at once**, rather than the tab in front + * of you. Four sessions for one sitting would ask a quorum the same question four + * times over what is plainly one decision -- see `EditGroupRelaysViewModel`, which + * carries the reasoning and works out what actually differs. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun EditGroupRelaysScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupRelaysUIState: EditGroupRelaysUIState = EditGroupRelaysUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val editGroupRelaysViewModel: EditGroupRelaysViewModel = viewModel( + factory = EditGroupRelaysViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupRelaysUIState = initialEditGroupRelaysUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in a click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_these_relays) + val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose) + + ScreenStateTransition(editGroupRelaysViewModel.editGroupRelaysUIState) { uiState -> + when (val editGroupRelaysUIState = uiState) { + is EditGroupRelaysUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState(message = editGroupRelaysUIState.message, onRetry = null) + } + + is EditGroupRelaysUIState.Loaded -> { + val urlFieldState = rememberTextFieldState() + val selectedSet = editGroupRelaysViewModel.selectedSet + val canSign = editGroupRelaysUIState.canSign + + // The editor's working copy comes from the state rather than from + // whatever loaded it, so a screen handed a loaded state -- a + // preview, a layout test -- fills in the same as the app does. + // Seeding leaves edits alone, so re-running this changes nothing. + LaunchedEffect(editGroupRelaysUIState.signed) { + editGroupRelaysViewModel.seedWorkingCopy(editGroupRelaysUIState.signed) + } + + // A refusal is about what is in the field, so it goes the moment + // the field changes rather than sitting under a URL that has since + // been corrected. + LaunchedEffect(urlFieldState) { + snapshotFlow { urlFieldState.text.toString() } + .collect { editGroupRelaysViewModel.clearAddFailure() } + } + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + editGroupRelaysUIState.localChatRoom.RenderChatRoomTitleText() + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + editGroupRelaysViewModel.proposeRelayLists( + localChatRoom = + editGroupRelaysUIState.localChatRoom, + signed = editGroupRelaysUIState.signed, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. The lists have not + // changed yet -- they change when a + // quorum signs -- so landing on a + // group still showing the old ones + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // Both stay on the form with the edits + // still in it. One is a question and + // one is a failure, and neither is a + // reason to throw away the typing. + onNothingToPropose = { notify(nothingChanged) }, + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon( + Icons.Default.Draw, + contentDescription = "Propose relays" + ) + Text(stringResource(Res.string.propose_relays)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + PrimaryScrollableTabRow( + modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), + edgePadding = 10.dp, + selectedTabIndex = selectedSet.ordinal, + ) { + GroupRelaySet.entries.forEach { set -> + Tab( + selected = set == selectedSet, + onClick = { editGroupRelaysViewModel.selectSet(set) }, + text = { Text(text = stringResource(set.label())) } + ) + } + } + + Column( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(Res.string.where_the_group_lives_on_nostr), + style = MaterialTheme.typography.titleSmall + ) + + // What this tab's list is actually for. Four relay lists + // is four questions nobody can be expected to hold apart + // from their names alone. + Text( + text = stringResource(selectedSet.purpose()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource( + Res.string.the_group_signs_each_list_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (selectedSet == GroupRelaySet.Blocked) { + // Said where it matters. A person's client keeps + // this list encrypted, and somebody blocking a relay + // here should know the group cannot. + Text( + text = stringResource( + Res.string.relays_are_public_a_group_cannot_encrypt + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_relays + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = urlFieldState, + lineLimits = TextFieldLineLimits.SingleLine, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(Icons.Default.Dns, contentDescription = Decorative) + }, + label = { Text(stringResource(Res.string.relay_url)) }, + placeholder = { + Text(stringResource(Res.string.eg_wss_relay_example_com)) + }, + isError = editGroupRelaysViewModel.addFailure != null + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + IconButton( + onClick = { + if ( + editGroupRelaysViewModel.addRelay( + urlFieldState.text.toString() + ) + ) { + urlFieldState.clearText() + } + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add relay" + ) + } + } + + // Named rather than silent. An add button that does + // nothing for a URL it refuses is indistinguishable from + // a missed tap. + editGroupRelaysViewModel.addFailure?.let { failure -> + Text( + text = when (failure) { + EditGroupRelaysViewModel.AddFailure.NotARelay -> + stringResource(Res.string.that_is_not_a_relay_address) + EditGroupRelaysViewModel.AddFailure.AlreadyListed -> + stringResource( + Res.string.that_relay_is_already_in_this_list + ) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + val relays = editGroupRelaysViewModel.relaysOf(selectedSet) + + if (relays.isEmpty()) { + EmptyState( + message = stringResource(Res.string.no_relays_in_this_list), + icon = Icons.Default.Dns + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.screenMargin) + ) { + items(items = relays, key = { it.url.url }) { relay -> + RelayRow( + relay = relay, + hasReadWriteMarkers = selectedSet.hasReadWriteMarkers, + onToggleRead = { + editGroupRelaysViewModel + .toggleRead(selectedSet, relay) + }, + onToggleWrite = { + editGroupRelaysViewModel + .toggleWrite(selectedSet, relay) + }, + onRemove = { + editGroupRelaysViewModel + .removeRelay(selectedSet, relay) + } + ) + } + } + } + } + } + } + + EditGroupRelaysUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.edit_relays), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialEditGroupRelaysUIState == EditGroupRelaysUIState.Loading) { + editGroupRelaysViewModel.initiateEditGroupRelays() + } + } +} + +/** + * The tab's name, and the sentence saying what its list decides. + * + * Kept here rather than on [GroupRelaySet] so the enum stays what it is -- four + * nostr kinds and how to read and write them -- with no catalogue behind it. A + * `when` rather than a field on each entry, so adding a set is a compile error here + * rather than a tab with no name. + */ +private fun GroupRelaySet.label(): StringResource = when (this) { + GroupRelaySet.General -> Res.string.relay_set_general + GroupRelaySet.Messages -> Res.string.relay_set_messages + GroupRelaySet.Search -> Res.string.relay_set_search + GroupRelaySet.Blocked -> Res.string.relay_set_blocked +} + +private fun GroupRelaySet.purpose(): StringResource = when (this) { + GroupRelaySet.General -> Res.string.relay_set_general_purpose + GroupRelaySet.Messages -> Res.string.relay_set_messages_purpose + GroupRelaySet.Search -> Res.string.relay_set_search_purpose + GroupRelaySet.Blocked -> Res.string.relay_set_blocked_purpose +} + +/** + * One relay of one list. + * + * The read and write chips only in [hasReadWriteMarkers], which is NIP-65 alone -- + * the other three kinds have a relay in the list or not, and a chip there would + * offer a distinction the event cannot carry. + */ +@Composable +private fun RelayRow( + relay: GroupRelay, + hasReadWriteMarkers: Boolean, + onToggleRead: () -> Unit, + onToggleWrite: () -> Unit, + onRemove: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + // The host rather than the whole URL: every relay here starts + // `wss://` -- nothing else is allowed in -- so the scheme is four + // characters of nothing in front of the part that differs. + text = relay.displayUrl(), + style = MaterialTheme.typography.bodyMedium + ) + + if (hasReadWriteMarkers) { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + FilterChip( + selected = relay.read, + onClick = onToggleRead, + label = { Text(stringResource(Res.string.relay_read)) } + ) + FilterChip( + selected = relay.write, + onClick = onToggleWrite, + label = { Text(stringResource(Res.string.relay_write)) } + ) + } + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove relay", + tint = MaterialTheme.colorScheme.error + ) + } + } +} + +@ConformancePreviews +@Composable +private fun EditGroupRelaysScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + EditGroupRelaysScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // Unsigned lists, which is a group opening this for the first + // time -- so the editor fills in from `GroupRelaySet.defaults` + // and the preview shows the relay a new group actually starts + // with rather than an empty tab. + signed = GroupRelayList.allAmong(emptyList(), "publicKey"), + // A group that can sign, so the form renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 2cdc39de..7273e49d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -40,6 +40,8 @@ import press.mantra.compose.ui.composable.DkgJoinApprovalScreen import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen +import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen +import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen import press.mantra.compose.ui.composable.KeyPackageManagementScreen @@ -122,6 +124,8 @@ import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute @@ -968,6 +972,46 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + EditGroupNostrProfileScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the form so back returns to the group rather than + // to an edit whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + EditGroupRelaysScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the editor so back returns to the group rather + // than to lists whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt new file mode 100644 index 00000000..426ce90c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class EditGroupNostrProfileRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt new file mode 100644 index 00000000..5c0fe7f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class EditGroupRelaysRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index 75db2d21..730d8046 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -340,6 +340,25 @@ fun ChatTranscript( return@items } + // The group's own profile, signed. Same treatment + // and for the same reason: nobody said it. Answered + // and settled because it reports rather than asks -- + // a quorum has already signed by the time this line + // exists. + // + // It leads nowhere. What a reader wants from one is + // the profile, and the nostr profile section of the + // group's detail screen is where that lives. + if (localChatMessage.chatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = true, + isSettled = true, + onClick = {} + ) + return@items + } + // Signing lines are the same kind of thing and get // the same treatment -- nobody said them either -- // but they lead somewhere else, because what a diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt index d0b1a198..d5c42b01 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt @@ -43,6 +43,35 @@ fun ProfileAvatar( shape: Shape = CircleShape, publicKey: String, profile: press.mantra.compose.database.model.Profile? +) { + ProfileAvatar( + size = size, + shape = shape, + publicKey = publicKey, + picture = profile?.picture, + name = profile?.displayName + ) +} + +/** + * The same avatar for a subject that has no `Profile` row. + * + * A group's is the case in hand: its kind:0 is signed by the group and kept as a + * `GroupSignedEvent`, which is not a `NostrEvent` and so cannot have a `Profile` + * hanging off it -- see `GroupNostrProfile`. The picture and the name are all this + * ever wanted from a profile, so they are what it takes. + * + * [name] only reaches the content description. The tint behind a missing picture + * comes from [publicKey], which is what keeps the same subject the same colour + * everywhere it appears. + */ +@Composable +fun ProfileAvatar( + size: Dp = 55.dp, + shape: Shape = CircleShape, + publicKey: String, + picture: String?, + name: String? = null ) { val modifier = Modifier.size(size).clip(shape = shape) @@ -50,7 +79,7 @@ fun ProfileAvatar( publicKey ) - if (profile?.picture == null) { + if (picture == null) { Icon( modifier = modifier, imageVector = Icons.Default.AccountCircle, @@ -77,9 +106,9 @@ fun ProfileAvatar( ) AsyncImage( - model = profile.picture, + model = picture, modifier = modifier, - contentDescription = "Profile picture for ${profile.displayName ?: profile.publicKey}", + contentDescription = "Profile picture for ${name ?: publicKey}", placeholder = placeHolderGraphic, fallback = fallbackGraphic, error = errorGraphic, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 3bfb3db2..9a807bf7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -74,6 +74,12 @@ class ChatRoomDetailViewModel( // Initiate new chat... and navigate to new chat... ChatRoomDetailUIState.Error("Missing chat room") } else { + // One read of the capability for the two things that need it. It + // walks the room's ceremonies looking for a share, so asking twice + // would do the same walk twice for one answer. + val canSign = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId) + ChatRoomDetailUIState.Loaded( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), @@ -82,8 +88,10 @@ class ChatRoomDetailViewModel( signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), parentChatRoomId = chatRepository.parentOf(chatRoomId), - canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && - chatRepository.canSign(chatRoomId), + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + groupRelayLists = chatRepository.groupRelayLists(chatRoomId), + canEditNostrProfile = canSign, + canAddSubgroup = canSign, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt new file mode 100644 index 00000000..ff8d81f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt @@ -0,0 +1,161 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Asks the group to sign what it says about itself on nostr. + * + * The room's id is the pubkey the group signs as, so the kind:0 this proposes is + * the profile the rest of nostr would read for that key -- authored by the group + * rather than by whoever filled the form in. Nothing is saved when the button is + * pressed: what goes out is a proposal, and the profile changes on every member's + * device at once when a quorum has signed it. + */ +class EditGroupNostrProfileViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var editGroupNostrProfileUIState: EditGroupNostrProfileUIState by mutableStateOf( + initialEditGroupNostrProfileUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateEditGroupNostrProfile() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + editGroupNostrProfileUIState = if (localChatRoom == null) { + EditGroupNostrProfileUIState.Error("Couldn't find the chat room") + } else { + EditGroupNostrProfileUIState.Loaded( + localChatRoom = localChatRoom, + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + // A NIP-17 room has no key of its own to sign as, so it has no + // nostr identity to describe -- the same condition the group + // detail screen gates the entry point on. + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Opens a session over one kind:0 for the group's own key. + * + * A blank field is not a field left alone: it clears what the group had said, + * because that is the only way to unsay it. [GroupNostrProfile.template] hands + * every field to `MetadataEvent`, whose rule for these arguments is that empty + * deletes the key -- and it builds on the group's newest profile, so a field + * this form does not offer is carried across rather than wiped. + * + * The fields are not cleared on success, unlike the add forms': a profile is + * standing state rather than a thing being added, and this screen is replaced + * by the signing session anyway. + */ + fun proposeNostrProfile( + localChatRoom: LocalChatRoom, + groupNostrProfile: GroupNostrProfile?, + nameField: TextFieldState, + aboutField: TextFieldState, + pictureField: TextFieldState, + websiteField: TextFieldState, + nip05Field: TextFieldState, + lud16Field: TextFieldState, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits from repeated taps. A second session over a + // second kind:0 would leave the group's profile decided by whichever + // quorum finished last. + if (isActionPending.value) return + isActionPending.value = true + + val template = GroupNostrProfile.template( + latest = groupNostrProfile, + name = nameField.text.toString().trim(), + about = aboutField.text.toString().trim(), + picture = pictureField.text.toString().trim(), + website = websiteField.text.toString().trim(), + nip05 = nip05Field.text.toString().trim(), + lud16 = lud16Field.text.toString().trim(), + ) + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = template.kind, + tags = template.tags, + content = template.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a nostr profile for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "EditGroupNostrProfileViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState = + EditGroupNostrProfileUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + EditGroupNostrProfileViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt new file mode 100644 index 00000000..fbae1c94 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt @@ -0,0 +1,299 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupRelaysUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * The group's relay lists, edited locally and proposed together. + * + * Nothing here is saved. The working copy lives in [working] until the button is + * pressed, and what the button does is open one signing session over the sets that + * changed -- so the group's relay lists change on every member's device at once, + * when a quorum has signed, or not at all. + * + * ### Why one session rather than one per set + * + * Wisp publishes a tab at a time because a person's client signs for itself and + * there is nothing to coordinate. Here every signature costs a quorum's attention, + * and four separate sessions for one sitting at one screen would ask for it four + * times over what is plainly a single decision: where this group lives. + * + * The batch carries **only the sets that differ**, which is what keeps that honest. + * Re-signing a list nobody touched would put a second, identical statement on the + * record with a newer timestamp -- harmless, since these are replaceable, and still + * a lie about when the group last decided anything. + * + * `FrostSigningManager` documents the cost of batching: a batch is all-or-nothing + * and so only as available as its worst item. These four items are the same size, + * the same shape and signed by the same quorum in the same second, so there is no + * worst one to be dragged down by. + */ +class EditGroupRelaysViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialEditGroupRelaysUIState: EditGroupRelaysUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var editGroupRelaysUIState: EditGroupRelaysUIState by mutableStateOf( + initialEditGroupRelaysUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * The working copy: what the lists would be if the group agreed. + * + * A snapshot map so the editor recomposes as rows are added and removed, and + * held on the view model rather than in the composition so that switching tabs + * -- which is a pager page changing -- does not throw away an edit. + */ + val working = mutableStateMapOf>() + + /** Which set the editor is showing, and so what the add field adds to. */ + var selectedSet: GroupRelaySet by mutableStateOf(GroupRelaySet.entries.first()) + private set + + /** + * Why the last add did not happen, or null. Cleared by the next keystroke. + * + * Named rather than silent: Wisp's add button does nothing at all for a URL it + * refuses, and "nothing happened" is indistinguishable from a missed tap. + */ + var addFailure: AddFailure? by mutableStateOf(null) + private set + + enum class AddFailure { + /** Not a `wss://` URL, or one pointing at this machine. */ + NotARelay, + + /** Already in this set, which is a no-op rather than a mistake. */ + AlreadyListed, + } + + fun initiateEditGroupRelays() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + editGroupRelaysUIState = if (localChatRoom == null) { + EditGroupRelaysUIState.Error("Couldn't find the chat room") + } else { + EditGroupRelaysUIState.Loaded( + localChatRoom = localChatRoom, + signed = chatRepository.groupRelayLists(chatRoomId), + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Fills the working copy from what the group signed, leaving edits alone. + * + * A set the group has agreed starts at its list; one it has not starts at the + * set's defaults, which is what puts this build's own relay in front of a group + * setting up for the first time rather than an empty list. See + * `GroupRelaySet.defaults`. + * + * Called from the screen rather than from [initiateEditGroupRelays], because + * the loader is not the only way a loaded state arrives -- a preview and a + * layout test both hand one straight in, and seeding only on the load path gave + * both of them an editor with nothing in it while the app worked fine. The + * screen has the state either way. + * + * **Only fills what is missing**, so running again after the member has typed + * something does not undo it. That matters because the effect that calls this + * is keyed on a state the view model can re-emit. + */ + fun seedWorkingCopy(signed: List) { + signed.forEach { list -> + if (list.set in working) return@forEach + + working[list.set] = if (list.isAgreed) list.relays else list.set.defaults() + } + } + + fun selectSet(set: GroupRelaySet) { + selectedSet = set + addFailure = null + } + + fun clearAddFailure() { + addFailure = null + } + + fun relaysOf(set: GroupRelaySet): List = working[set] ?: emptyList() + + /** + * Adds [url] to the selected set, or says why it did not. + * + * True when the row appeared, which is the caller's cue to clear the field. + */ + fun addRelay(url: String): Boolean { + val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run { + addFailure = AddFailure.NotARelay + return false + } + + val current = relaysOf(selectedSet) + if (current.any { it.url == relayUrl }) { + addFailure = AddFailure.AlreadyListed + return false + } + + working[selectedSet] = current + GroupRelay(relayUrl) + addFailure = null + return true + } + + fun removeRelay(set: GroupRelaySet, relay: GroupRelay) { + working[set] = relaysOf(set).filterNot { it.url == relay.url } + } + + /** + * Turns a NIP-65 marker on or off, refusing to turn off the last one. + * + * A relay that is neither read nor write has no tag in NIP-65 -- see + * `GroupRelaySet.template` -- so the state does not exist to be put the group's + * signature on. What it would mean is that the relay is not in the list, and + * removing it is the row's own button. + */ + fun toggleRead(set: GroupRelaySet, relay: GroupRelay) = + toggle(set, relay) { it.copy(read = !it.read) } + + fun toggleWrite(set: GroupRelaySet, relay: GroupRelay) = + toggle(set, relay) { it.copy(write = !it.write) } + + private fun toggle( + set: GroupRelaySet, + relay: GroupRelay, + change: (GroupRelay) -> GroupRelay, + ) { + working[set] = relaysOf(set).map { + if (it.url != relay.url) return@map it + + change(it).takeIf { changed -> changed.read || changed.write } ?: it + } + } + + /** + * The sets whose working copy says something different to what the group + * signed, in the order the editor shows them. + * + * Order-sensitive on purpose. A relay list is written in the order it is read + * back, and a member who moved a relay to the front meant to; comparing as sets + * would call that no change and quietly refuse to propose it. + * + * A set the group has never agreed counts as changed as soon as it holds + * anything, so a first-time group's seeded defaults are a real proposal rather + * than an accident, and an empty one it has never agreed counts as unchanged -- + * there is nothing to say. + */ + fun changedSets(signed: List): List = + signed.filter { list -> relaysOf(list.set) != list.relays } + .filterNot { list -> !list.isAgreed && relaysOf(list.set).isEmpty() } + .map { it.set } + + /** + * Opens one session over every set that changed. + * + * [onNothingToPropose] rather than a session over nothing: + * `proposeSigningBatch` refuses an empty batch outright, and a member who has + * changed nothing is asking a question rather than making a mistake. + */ + fun proposeRelayLists( + localChatRoom: LocalChatRoom, + signed: List, + onSuccess: (sessionId: String) -> Unit, + onNothingToPropose: () -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. Two sessions over two versions of one relay + // list would leave the group's answer decided by whichever quorum finished + // last. + if (isActionPending.value) return + + val changed = changedSets(signed) + if (changed.isEmpty()) { + onNothingToPropose.invoke() + return + } + + isActionPending.value = true + + val templates = changed.map { set -> set.template(relaysOf(set)) } + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = templates, + ) + }.onFailure { error -> + logger.e("Failed to propose relay lists for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "EditGroupRelaysViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupRelaysUIState: EditGroupRelaysUIState = + EditGroupRelaysUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + EditGroupRelaysViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupRelaysUIState = initialEditGroupRelaysUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 80f08814..394336cd 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -7,6 +7,8 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList sealed interface ChatRoomDetailUIState { data class Loaded( @@ -50,6 +52,41 @@ sealed interface ChatRoomDetailUIState { * really is a subgroup. */ val parentChatRoomId: HexKey? = null, + /** + * The kind:0 the group signed about itself, or null while it has signed + * none. + * + * Sits under the room's identity and above its work, because that is what + * it is: the name and the picture the rest of nostr sees on the key the + * signing key row names. Null is an ordinary state and the screen says so + * -- a group has an identity from the moment it has a key and describes it + * whenever somebody gets round to it. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Where the group has said it lives on nostr, one entry per relay set. + * + * Beside the profile because they are the same kind of fact -- the group's + * own account of itself, signed by the group -- and read in the same breath + * by anybody checking whether the group is reachable. + * + * Empty only in a room that has none of this to say. Every other case has + * four entries, some of them unsigned; see `ChatRepository.groupRelayLists`. + */ + val groupRelayLists: List = emptyList(), + /** + * Whether this device could sign a profile for the group. + * + * The same capability [canAddSubgroup] wants, and wanted for the same + * reason: writing the group's profile is a signature by the group, and + * `FrostSigningManager.proposeSigningBatch` throws for a device holding no + * share of the key. It is not about permission -- any member with a share + * may propose -- and the quorum is what decides. + * + * A NIP-17 room is excluded by the same condition, since it has no key of + * its own to sign as and so no nostr identity to describe. + */ + val canEditNostrProfile: Boolean = false, /** * Whether this device could open a subgroup here at all. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt new file mode 100644 index 00000000..77f7ee64 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt @@ -0,0 +1,34 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile + +sealed interface EditGroupNostrProfileUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * What the group has already said about itself, or null the first time. + * + * The form starts from it, and so does the event: an edit is built with + * `MetadataEvent.updateFromPast` so a field this app does not offer survives + * being edited rather than being dropped by it. See + * [GroupNostrProfile.template]. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the form readable and the button inert, which is the honest + * shape: the profile is worth reading either way, and a member without a + * share cannot open a session for one. See + * `ChatRoomDetailUIState.canEditNostrProfile`. + */ + val canSign: Boolean = false, + ): EditGroupNostrProfileUIState + + data class Error( + val message: String + ): EditGroupNostrProfileUIState + + data object Loading: EditGroupNostrProfileUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt new file mode 100644 index 00000000..08f7963e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt @@ -0,0 +1,33 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelayList + +sealed interface EditGroupRelaysUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * What the group has signed for each set, which is what an edit is measured + * against. + * + * Kept beside the working copy rather than replaced by it, because the + * button only proposes the sets that actually differ -- and "differ" needs + * both halves. See `EditGroupRelaysViewModel.changedSets`. + */ + val signed: List = emptyList(), + /** + * Whether this device holds a share of the group's key. + * + * False leaves the lists readable and the button inert: where the group + * says it lives is worth reading whoever is looking, and proposing a change + * to it is a signature only a share-holder can start. + */ + val canSign: Boolean = false, + ): EditGroupRelaysUIState + + data class Error( + val message: String + ): EditGroupRelaysUIState + + data object Loading: EditGroupRelaysUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt new file mode 100644 index 00000000..944e7199 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt @@ -0,0 +1,438 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * What a group's nostr profile is allowed to be read out of. + * + * A room's id is the pubkey it signs as, so a kind:0 authored by that key is the + * group describing itself and a kind:0 authored by anything else is not -- however + * it came to be filed against the room. The reading is what the group detail screen + * shows and what an edit is built on top of, so both halves are worth pinning: + * which events become a profile, and what an edit does to the one before it. + * + * The edit half is the one with a silent failure behind it. `updateFromPast` is + * used precisely so a field this app does not offer survives being edited, and + * `createNew` in its place would compile, pass any test that only looked at the + * fields the form knows about, and quietly wipe the rest of the group's profile + * every time somebody fixed a typo in its name. + */ +class GroupNostrProfileTest { + private val participants = 3 + private val threshold = 2 + + /** The group whose profile this is. */ + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the profiles signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + private val strangerChatRoomId = + SharedKeyDerivation.marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path) + + @Test + fun `a kind zero the room signed is the group's profile`() { + val profile = GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"Translation collective","about":"We translate hard books.",""" + + """"picture":"https://example.com/group.png","nip05":"group@example.com"}""" + ), + chatRoomId = chatRoomId + ) + + assertNotNull(profile) + assertEquals("Translation collective", profile.name()) + assertEquals("We translate hard books.", profile.about()) + assertEquals("https://example.com/group.png", profile.picture()) + assertEquals("group@example.com", profile.nip05()) + assertEquals(chatRoomId, profile.publicKey) + } + + @Test + fun `display_name is what a name is read out of when both are set`() { + // Every other nostr client prefers it, and `template` writes both so they + // agree. A group whose two names disagree got them from somewhere else, + // and the answer has to be the one the rest of nostr will give. + val profile = GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"collective","display_name":"Translation collective"}""" + ), + chatRoomId = chatRoomId + ) + + assertEquals("Translation collective", profile?.name()) + } + + @Test + fun `a kind zero another group signed is not this group's profile`() { + // A real quorum and a real signature by a group with no standing to say + // anything about this room. The row names this room because that is where + // the event was filed; the author is what decides whose profile it is. + val stranger = GroupSignedEvent.fromEvent( + event = metadataEvent( + content = """{"name":"Somebody else"}""", + material = strangerMaterial + ), + chatRoomId = chatRoomId + ) + + assertEquals(strangerChatRoomId, stranger.publicKey, "the fixture signs as the stranger") + assertNull(GroupNostrProfile.of(signedEvent = stranger, chatRoomId = chatRoomId)) + } + + @Test + fun `a kind zero whose signature is not the room's is not a profile`() { + // Authored by the room, saying the right things, and signed by a key that + // is not the room's. This is the shape a member forging a rename produces. + assertNull( + GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"Renamed by one member"}""", + signer = strangerMaterial + ), + chatRoomId = chatRoomId + ) + ) + + // And everything off the wire is allowed to be nonsense, which means no + // rather than an exception. + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupNostrProfile.of( + signedEvent = signed(content = """{"name":"x"}""", signature = rubbish), + chatRoomId = chatRoomId + ), + "a profile signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `an event of another kind is not a profile`() { + assertNull( + GroupNostrProfile.of( + signedEvent = signed(content = """{"name":"x"}""").copy(kind = 1), + chatRoomId = chatRoomId + ) + ) + } + + @Test + fun `the newest profile the group signed is the one that counts`() { + // kind:0 is replaceable: editing a profile signs a second one, and the + // second is the answer whatever order the query hands them over in. + val older = signed(content = """{"name":"What it was called"}""", createdAt = 1_700_000_000) + val newer = signed(content = """{"name":"What it is called"}""", createdAt = 1_700_000_900) + + listOf(listOf(older, newer), listOf(newer, older)).forEach { events -> + assertEquals( + "What it is called", + GroupNostrProfile.newestAmong(events, chatRoomId)?.name(), + "the newest profile lost to query order" + ) + } + } + + @Test + fun `two profiles signed in the same second resolve the same way on every device`() { + // Two devices catching up read the same events in whatever order the + // relay or the query gives them, and they have to end up showing the same + // profile. A tie on the timestamp is broken by the id, which every device + // agrees on because it is a hash of the event. + val one = signed(content = """{"name":"One"}""", createdAt = 1_700_000_000) + val other = signed(content = """{"name":"Other"}""", createdAt = 1_700_000_000) + + val expected = GroupNostrProfile.newestAmong(listOf(one, other), chatRoomId)?.name() + + assertNotNull(expected) + assertEquals( + expected, + GroupNostrProfile.newestAmong(listOf(other, one), chatRoomId)?.name(), + "a tie on the timestamp was broken differently by the two orderings" + ) + assertEquals( + if (one.id > other.id) "One" else "Other", + expected, + "the tie should break on the id, which is the only thing both devices share" + ) + } + + @Test + fun `the first profile a group signs carries the fields it was given`() { + val template = GroupNostrProfile.template( + latest = null, + name = "Translation collective", + about = "We translate hard books.", + picture = "https://example.com/group.png", + website = "https://example.com", + nip05 = "group@example.com", + lud16 = "group@getalby.com" + ) + + val profile = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + ) + + assertNotNull(profile) + assertEquals(MetadataEvent.KIND, template.kind) + assertEquals("Translation collective", profile.name()) + assertEquals("We translate hard books.", profile.about()) + assertEquals("https://example.com/group.png", profile.picture()) + assertEquals("https://example.com", profile.website()) + assertEquals("group@example.com", profile.nip05()) + assertEquals("group@getalby.com", profile.lud16()) + } + + @Test + fun `an edit keeps a field the form does not offer`() { + // The reason `template` builds on the group's own event rather than from + // scratch. `bot` is not on the form and never will be reached by it; an + // edit that dropped it would be this app deciding a group is not a bot + // because somebody fixed its name. + val existing = GroupNostrProfile.of( + signedEvent = signed(content = """{"name":"Old name","bot":true}"""), + chatRoomId = chatRoomId + ) + assertNotNull(existing) + + val template = GroupNostrProfile.template( + latest = existing, + name = "New name", + about = "", + picture = "", + website = "", + nip05 = "", + lud16 = "" + ) + + val edited = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + ) + + assertNotNull(edited) + assertEquals("New name", edited.name()) + assertEquals(true, edited.metadata.bot, "an edit dropped a field the form does not offer") + } + + @Test + fun `an emptied field unsays what the group had said`() { + // The only way to withdraw something a group has signed. A form that sent + // nulls for its blanks could add to a profile and never subtract from one. + val existing = GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"Translation collective","about":"Something regretted"}""" + ), + chatRoomId = chatRoomId + ) + assertNotNull(existing) + + val template = GroupNostrProfile.template( + latest = existing, + name = "Translation collective", + about = "", + picture = "", + website = "", + nip05 = "", + lud16 = "" + ) + + val edited = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + ) + + assertNotNull(edited) + assertNull(edited.about()) + assertEquals("Translation collective", edited.name()) + } + + @Test + fun `a name is written to both of the keys a reader might look in`() { + val template = GroupNostrProfile.template( + latest = null, + name = "Translation collective", + about = "", + picture = "", + website = "", + nip05 = "", + lud16 = "" + ) + + val metadata = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + )?.metadata + + assertNotNull(metadata) + assertEquals("Translation collective", metadata.name) + assertEquals( + "Translation collective", + metadata.displayName, + "a client preferring display_name would show the old name after an edit" + ) + } + + @Test + fun `an empty profile is a profile rather than a parse failure`() { + // Wiping a profile is something a group is entitled to do, and an empty + // kind:0 is how nostr says it. Reading it as nothing would leave the + // screen claiming the group had never said anything. + val profile = GroupNostrProfile.of( + signedEvent = signed(content = ""), + chatRoomId = chatRoomId + ) + + assertNotNull(profile) + assertNull(profile.name()) + assertNull(profile.about()) + } + + @Test + fun `content that is not a profile at all is refused`() { + assertNull( + GroupNostrProfile.of( + signedEvent = signed(content = "not json"), + chatRoomId = chatRoomId + ) + ) + } + + /** + * A profile row as `FrostSigningManager.complete` files one: the content under + * the room's own key, with the id hashed over it and the group's signature on + * it. + * + * [signer] is pulled apart from the author so a signature by the wrong quorum + * can be tested, and [signature] replaces the real one outright for what is + * not a signature at all. + */ + private fun signed( + content: String, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = metadataEvent( + content = content, + createdAt = createdAt, + material = material, + signer = signer, + signature = signature + ), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun metadataEvent( + content: String, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + * + * Assembled any other way it would not be evidence about the signatures this + * app actually produces. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt new file mode 100644 index 00000000..e58088ab --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt @@ -0,0 +1,398 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a group's relay lists are allowed to be read out of, and what an edit + * produces. + * + * The reading half is `GroupNostrProfileTest`'s again -- the room has to be the + * author, the signature has to be the room's -- and it is repeated here because + * these are four different kinds through two different tag vocabularies, and a + * mistake in any one of them would be invisible in the other three. + * + * The writing half is the one that could not be settled by reading the code. + * NIP-65's markers are absent for both, `read` for read-only and `write` for + * write-only, so the tag for the ordinary case is the one with the least in it -- + * exactly the shape a round trip is most likely to lose. And "neither" is a state + * the format cannot express at all, which is why the editor refuses it and why + * building one drops it rather than writing a tag that means the opposite. + */ +class GroupRelayListTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the lists signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + + @Test + fun `every set round trips through the tags it is written in`() { + // Four kinds and two tag vocabularies: NIP-65's "r" for General and the + // "relay" tag for the other three, through two RelayTag classes that are + // different classes in different packages. A set wired to the wrong one + // writes tags nothing reads back. + GroupRelaySet.entries.forEach { set -> + val relays = listOf(GroupRelay(one), GroupRelay(two)) + val list = GroupRelayList.of( + signedEvent = signed(set.template(relays)), + chatRoomId = chatRoomId, + set = set + ) + + assertEquals( + listOf(one, two), + list?.relays?.map { it.url }, + "${set.name} did not round trip through its own tags" + ) + } + } + + @Test + fun `a relay list only reads back as the set it was written as`() { + // The kinds are the only thing separating a general list from a blocked + // one, and reading a blocked list as a general one would have the group + // publishing to the relays it refuses to talk to. + val blocked = signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(one)))) + + assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.General)) + assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Search)) + assertEquals( + listOf(one), + GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Blocked)?.relays?.map { it.url } + ) + } + + @Test + fun `read and write markers survive being written and read`() { + // The ordinary case is the dangerous one: NIP-65 writes both by *omitting* + // the marker, so a round trip that lost the third element entirely would + // still look correct for a both-ways relay and would silently turn a + // read-only relay into a write one. + val relays = listOf( + GroupRelay(one, read = true, write = true), + GroupRelay(two, read = true, write = false), + GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example"), + read = false, write = true), + ) + + val readBack = GroupRelayList.of( + signedEvent = signed(GroupRelaySet.General.template(relays)), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ) + + assertEquals(relays, readBack?.relays) + } + + @Test + fun `a relay that is neither read nor write is not written at all`() { + // NIP-65 has no marker for it. The editor will not produce the state, and + // this is the second line of that defence: writing a bare `r` tag would + // advertise it as both, which is the opposite of what was asked. + val list = GroupRelayList.of( + signedEvent = signed( + GroupRelaySet.General.template( + listOf( + GroupRelay(one, read = false, write = false), + GroupRelay(two), + ) + ) + ), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ) + + assertEquals(listOf(two), list?.relays?.map { it.url }) + } + + @Test + fun `a relay list another group signed is not this group's`() { + val stranger = GroupSignedEvent.fromEvent( + event = relayEvent( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + material = strangerMaterial + ), + chatRoomId = chatRoomId + ) + + assertNull(GroupRelayList.of(stranger, chatRoomId, GroupRelaySet.General)) + } + + @Test + fun `a relay list the room did not sign is not a relay list`() { + // Authored by the room and signed by somebody else -- the shape a member + // redirecting a group's traffic would produce. + assertNull( + GroupRelayList.of( + signedEvent = signed( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + signer = strangerMaterial + ), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ) + ) + + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupRelayList.of( + signedEvent = signed( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + signature = rubbish + ), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ), + "a list signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `a set the group never agreed reads back unsigned and empty`() { + // Not null. The screen has a row per set whatever the group has said, and + // `isAgreed` is what separates "never said" from "said nothing". + val list = GroupRelayList.newestAmong(emptyList(), chatRoomId, GroupRelaySet.Search) + + assertFalse(list.isAgreed) + assertEquals(emptyList(), list.relays) + assertEquals(GroupRelaySet.Search, list.set) + assertNull(list.signedAt) + } + + @Test + fun `an agreed empty list is not the same as one never agreed`() { + // A group withdrawing its relay list signs an empty one, and that decision + // has to survive being read back -- otherwise a deliberate withdrawal looks + // exactly like never having got round to it. + val list = GroupRelayList.of( + signedEvent = signed(GroupRelaySet.Messages.template(emptyList())), + chatRoomId = chatRoomId, + set = GroupRelaySet.Messages + ) + + assertTrue(list?.isAgreed == true) + assertEquals(emptyList(), list?.relays) + } + + @Test + fun `the newest list the group signed is the one that counts`() { + val older = signed( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + createdAt = 1_700_000_000 + ) + val newer = signed( + GroupRelaySet.General.template(listOf(GroupRelay(two))), + createdAt = 1_700_000_900 + ) + + listOf(listOf(older, newer), listOf(newer, older)).forEach { events -> + assertEquals( + listOf(two), + GroupRelayList.newestAmong(events, chatRoomId, GroupRelaySet.General) + .relays.map { it.url }, + "the newest relay list lost to query order" + ) + } + } + + @Test + fun `allAmong sorts every set into one entry each`() { + val events = listOf( + signed(GroupRelaySet.General.template(listOf(GroupRelay(one)))), + signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(two)))), + ) + + val all = GroupRelayList.allAmong(events, chatRoomId) + + assertEquals(GroupRelaySet.entries.toList(), all.map { it.set }) + assertEquals(listOf(one), all.first { it.set == GroupRelaySet.General }.relays.map { it.url }) + assertEquals(listOf(two), all.first { it.set == GroupRelaySet.Blocked }.relays.map { it.url }) + assertFalse(all.first { it.set == GroupRelaySet.Search }.isAgreed) + assertFalse(all.first { it.set == GroupRelaySet.Messages }.isAgreed) + } + + @Test + fun `every set but blocked starts at the app's own relay`() { + // What a group setting up for the first time is shown. Seeding with nothing + // would make its first proposal an empty list, which says less than never + // having said anything. + GroupRelaySet.entries.filterNot { it == GroupRelaySet.Blocked }.forEach { set -> + assertEquals( + listOf(Relays.ephemeral), + set.defaults().map { it.url }, + "${set.name} did not default to the app's relay" + ) + } + + // And blocked starts empty: a default there is a refusal to talk to + // somebody that nobody in the group chose. + assertEquals(emptyList(), GroupRelaySet.Blocked.defaults()) + } + + @Test + fun `only a wss relay somewhere other than this device is accepted`() { + assertEquals(one, GroupRelaySet.relayUrlOrNull("wss://relay.one.example")) + assertEquals(one, GroupRelaySet.relayUrlOrNull(" wss://relay.one.example ")) + + // A group's list is read by strangers over the open network: ws:// asks + // them to fetch it in the clear, and loopback names a relay only one device + // can reach. + listOf( + "ws://relay.one.example", + "http://relay.one.example", + "relay.one.example", + "wss://localhost", + "wss://127.0.0.1", + "", + ).forEach { + assertNull(GroupRelaySet.relayUrlOrNull(it), "\"$it\" was accepted as a relay") + } + } + + @Test + fun `a duplicated relay is read once`() { + // Two tags for one relay is one relay said twice, and a list that shows it + // twice reads as two to a human -- the same reasoning a birth certificate's + // admin roster is deduplicated on. + val duplicated = Event( + id = "", + pubKey = chatRoomId, + createdAt = 1_700_000_000, + kind = AdvertisedRelayListEvent.KIND, + tags = arrayOf(arrayOf("r", one.url), arrayOf("r", one.url, "read")), + content = "", + sig = "" + ) + + assertEquals(listOf(one), GroupRelaySet.General.parse(duplicated.tags).map { it.url }) + } + + /** A relay list as `FrostSigningManager.complete` files one. */ + private fun signed( + template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = relayEvent(template, createdAt, material, signer, signature), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun relayEvent( + template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt new file mode 100644 index 00000000..1d0d4eb4 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -0,0 +1,278 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ChatRoomDetailUIState +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Where the group's nostr profile sits, and when it is there at all. + * + * The section's contents need no test -- a name and a line of prose in a + * `ListItem` -- but two things about it do, and neither is settled by reading the + * screen top to bottom. + * + * **Its place in the order.** It goes under what the room *is* -- the key it signs + * as and whose child it is -- and above what the room has *made*, because a + * profile is part of the identity rather than part of the work. That ordering is + * an intention that a later `item {}` inserted in the wrong place would silently + * undo, and the screen is long enough that nobody would notice. + * + * **Its absence in a NIP-17 room.** A NIP-17 room's id is a conversation, not a + * key it can sign as, so it has no nostr identity and never will. An empty section + * there would promise something that is not coming, which is worse than saying + * nothing. + */ +@OptIn(ExperimentalTestApi::class) +class GroupNostrProfileSectionJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val profile = GroupNostrProfile( + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + // Built rather than read out of the event: the reading checks a real + // signature, and this test is about the screen rather than about that. + metadata = UserMetadata().apply { + displayName = "Translation collective" + about = "A group translating hard books into Sesotho." + nip05 = "group@example.com" + } + ) + + @Test + fun `the profile sits under the signing key and above the library`() = render( + state(groupNostrProfile = profile) + ) { + val signingKey = onNodeWithText("Signing key").getBoundsInRoot().top + val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top + val library = onNodeWithText("Library").getBoundsInRoot().top + + assertTrue( + signingKey < nostrProfile, + "the nostr profile section climbed above the signing key row" + ) + assertTrue( + nostrProfile < library, + "the nostr profile section fell below the library" + ) + + onNodeWithText("Translation collective").assertIsDisplayed() + onNodeWithText("group@example.com").assertIsDisplayed() + } + + @Test + fun `the relay lists sit under the profile and above the library`() = render( + state(groupNostrProfile = profile, relayLists = signedRelayLists()) + ) { + val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top + val relays = onNodeWithText("Relays").getBoundsInRoot().top + val library = onNodeWithText("Library").getBoundsInRoot().top + + assertTrue(nostrProfile < relays, "the relay lists climbed above the profile") + assertTrue(relays < library, "the relay lists fell below the library") + + // Every set gets a row whatever the group has said, and the two absences + // read differently: one has never been agreed, the other was agreed empty. + onNodeWithText("General").assertIsDisplayed() + onNodeWithText("relay.one.example").assertIsDisplayed() + onNodeWithText("Messages").assertIsDisplayed() + onNodeWithText("Search").assertIsDisplayed() + onNodeWithText("Blocked").assertIsDisplayed() + // Two of them, because Messages and Search are both unagreed -- and the + // count is the assertion: a row per set, not a row per set the group has + // got round to. + onAllNodesWithText("Not set yet").assertCountEquals(2) + onNodeWithText("No relays in this list").assertIsDisplayed() + onNodeWithText("Edit relays").assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key reads the relays and is offered no edit`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + canEditNostrProfile = false + ) + ) { + onNodeWithText("General").assertIsDisplayed() + onNodeWithText("relay.one.example").assertIsDisplayed() + onNodeWithText("Edit relays").assertDoesNotExist() + } + + @Test + fun `a group that has said nothing says so, and offers to say something`() = render( + state(groupNostrProfile = null) + ) { + onNodeWithText("Nostr profile").assertIsDisplayed() + onNodeWithText("This group hasn't said anything about itself on Nostr yet.") + .assertIsDisplayed() + onNodeWithText("Edit Nostr profile").assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key reads the profile and is offered no edit`() = render( + state(groupNostrProfile = profile, canEditNostrProfile = false) + ) { + // The profile is worth reading whoever is looking; proposing one needs a + // share, and `proposeSigningBatch` throws without one. + onNodeWithText("Translation collective").assertIsDisplayed() + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + } + + @Test + fun `a NIP-17 room has no nostr identity and so no section`() = render( + state(groupNostrProfile = null, mlsGroupState = null, canEditNostrProfile = false) + ) { + onNodeWithText("Nostr profile").assertDoesNotExist() + onNodeWithText("This group hasn't said anything about itself on Nostr yet.") + .assertDoesNotExist() + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + + // The relay lists go with it: they describe a nostr identity this room + // does not have. + onNodeWithText("Relays").assertDoesNotExist() + onNodeWithText("Edit relays").assertDoesNotExist() + + // The rest of the screen is untouched, so the section's absence is an + // absence rather than a screen that failed to draw. + onNodeWithText("Library").assertIsDisplayed() + } + + /** + * Two agreed sets and two absences, which is the only fixture that exercises + * the three things a row can say: a list, "not set yet", and "no relays". + */ + private fun signedRelayLists(): List = GroupRelaySet.entries.map { set -> + when (set) { + GroupRelaySet.General -> GroupRelayList( + set = set, + signedEvent = relaySignedEvent(set), + relays = listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example"))) + ) + // Agreed and empty: the group withdrew its list rather than never + // having had one. + GroupRelaySet.Blocked -> GroupRelayList( + set = set, + signedEvent = relaySignedEvent(set), + relays = emptyList() + ) + else -> GroupRelayList(set = set, signedEvent = null, relays = emptyList()) + } + } + + private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent( + id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + private fun state( + groupNostrProfile: GroupNostrProfile?, + relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), + canEditNostrProfile: Boolean = true, + mlsGroupState: String? = "state" + ) = ChatRoomDetailUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + // Deliberately not the profile's name. The room's subject is this + // device's name for it and the profile is the group's own, and a + // test that used one string for both could not tell them apart. + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = mlsGroupState + ) + ), + // A key state, so the signing key row the section has to sit under is on + // the screen to be measured against. Not a real key: nothing here derives. + groupKeyState = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = "c".repeat(64), + thresholdPublicKey = "02".padEnd(66, 'a'), + derivationPath = "m/9420/0/0", + announcedBy = chatRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000) + ), + groupNostrProfile = groupNostrProfile, + groupRelayLists = relayLists, + canEditNostrProfile = canEditNostrProfile + ) + + /** + * The screen at a phone's width and a window tall enough to compose the whole + * of it, since a `LazyColumn` does not lay out what it would not show. + * + * The state is passed in rather than loaded, so the NO_OP repositories are + * never asked for anything: `initiateChatRoomDetail` only runs for a screen + * that arrives in [ChatRoomDetailUIState.Loading]. + */ + private fun render( + uiState: ChatRoomDetailUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 4000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + ChatRoomDetailScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialChatRoomDetailUIState = uiState, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = {}, + onPopBackToRoute = {} + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt new file mode 100644 index 00000000..91ac1332 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt @@ -0,0 +1,224 @@ +package press.mantra.compose.ui.view.model + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupRelaysUIState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The two decisions the relay editor makes that nothing else can check. + * + * **What counts as a change.** The button proposes only the lists that differ, so + * this is what decides whether a quorum is asked at all -- and getting it wrong in + * either direction is quiet. Too eager and every visit re-signs four lists nobody + * touched, dating a decision the group did not make; too shy and an edit is + * silently dropped on a screen that said it had been sent. + * + * **What a NIP-65 marker is allowed to become.** A relay that is neither read nor + * write has no tag in the format, so the state must not be reachable. + * `GroupRelaySet.template` drops one as a last resort; this is where it is actually + * prevented. + */ +class EditGroupRelaysViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + + private fun viewModel() = EditGroupRelaysViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = "a".repeat(64), + relayHint = null, + initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loading, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + ) + + private fun signedEvent(set: GroupRelaySet) = GroupSignedEvent( + id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + /** Every set unsigned, which is a group opening the editor for the first time. */ + private fun nothingSigned() = GroupRelayList.allAmong(emptyList(), chatRoomId) + + private fun signed(set: GroupRelaySet, relays: List) = + nothingSigned().map { + if (it.set == set) GroupRelayList(set, signedEvent(set), relays) else it + } + + @Test + fun `a first-time group is seeded with the app's own relay and that is a change`() { + val signed = nothingSigned() + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + assertEquals( + listOf(Relays.ephemeral), + viewModel.relaysOf(GroupRelaySet.General).map { it.url }, + ) + + // Seeded and therefore proposable. A group whose editor filled itself in + // and then refused to send it would be showing a plan it will not carry out. + assertEquals( + listOf(GroupRelaySet.General, GroupRelaySet.Messages, GroupRelaySet.Search), + viewModel.changedSets(signed), + ) + } + + @Test + fun `blocked is never seeded and so is never proposed by simply opening the screen`() { + val signed = nothingSigned() + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + assertEquals(emptyList(), viewModel.relaysOf(GroupRelaySet.Blocked)) + assertFalse(GroupRelaySet.Blocked in viewModel.changedSets(signed)) + } + + @Test + fun `opening a group that has agreed everything proposes nothing`() { + // The case that has to be silent. A member opening the editor, reading it + // and pressing the button must not spend a quorum on four identical lists. + val signed = GroupRelaySet.entries.map { set -> + GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) + } + + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + assertEquals(emptyList(), viewModel.changedSets(signed)) + } + + @Test + fun `only the set that was edited is proposed`() { + val signed = GroupRelaySet.entries.map { set -> + GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) + } + + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + viewModel.selectSet(GroupRelaySet.Search) + assertTrue(viewModel.addRelay(two.url)) + + assertEquals(listOf(GroupRelaySet.Search), viewModel.changedSets(signed)) + } + + @Test + fun `reordering a list is a change`() { + // A relay list is written in the order it is read back, and a member who + // moved a relay to the front meant to. Comparing as sets would call this no + // change and refuse to propose it. + val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one), GroupRelay(two))) + + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + viewModel.selectSet(GroupRelaySet.General) + viewModel.removeRelay(GroupRelaySet.General, GroupRelay(one)) + assertTrue(viewModel.addRelay(one.url)) + + assertEquals( + listOf(two, one), + viewModel.relaysOf(GroupRelaySet.General).map { it.url }, + ) + assertTrue(GroupRelaySet.General in viewModel.changedSets(signed)) + } + + @Test + fun `emptying an agreed list is a change, and emptying an unagreed one is not`() { + // Withdrawing a list the group agreed is a decision worth a signature. + // "Still nothing" is not a decision at all, and proposing an empty list for + // a set nobody ever set would be a signature over silence. + val agreed = signed(GroupRelaySet.Messages, listOf(GroupRelay(one))) + val withdrawing = viewModel() + withdrawing.seedWorkingCopy(agreed) + withdrawing.removeRelay(GroupRelaySet.Messages, GroupRelay(one)) + + assertTrue(GroupRelaySet.Messages in withdrawing.changedSets(agreed)) + + val untouched = nothingSigned() + val quiet = viewModel() + quiet.seedWorkingCopy(untouched) + // Clear the seeded default so the set is unagreed *and* empty. + quiet.removeRelay(GroupRelaySet.Search, GroupRelay(Relays.ephemeral)) + + assertFalse(GroupRelaySet.Search in quiet.changedSets(untouched)) + } + + @Test + fun `a relay cannot be turned into one that is neither read nor write`() { + // NIP-65 has no tag for it, so the state has to be unreachable. What it + // would mean is that the relay is not in the list, and removing it is the + // row's own button. + val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + viewModel.toggleWrite(GroupRelaySet.General, GroupRelay(one)) + assertEquals( + listOf(GroupRelay(one, read = true, write = false)), + viewModel.relaysOf(GroupRelaySet.General), + ) + + // The second toggle is the one that would leave it as neither. + viewModel.toggleRead( + GroupRelaySet.General, + viewModel.relaysOf(GroupRelaySet.General).first(), + ) + assertEquals( + listOf(GroupRelay(one, read = true, write = false)), + viewModel.relaysOf(GroupRelaySet.General), + "a relay was left neither read nor write, which NIP-65 cannot express", + ) + } + + @Test + fun `a url that is not a relay is refused and named`() { + val viewModel = viewModel() + viewModel.seedWorkingCopy(nothingSigned()) + viewModel.selectSet(GroupRelaySet.Search) + + assertFalse(viewModel.addRelay("http://relay.one.example")) + assertEquals(EditGroupRelaysViewModel.AddFailure.NotARelay, viewModel.addFailure) + + // And a duplicate is a no-op rather than a mistake, said differently. + assertTrue(viewModel.addRelay(one.url)) + assertFalse(viewModel.addRelay(one.url)) + assertEquals(EditGroupRelaysViewModel.AddFailure.AlreadyListed, viewModel.addFailure) + } + + @Test + fun `seeding again leaves an edit alone`() { + // The effect that seeds is keyed on a state the view model can re-emit, so + // running twice has to be harmless -- otherwise a reload halfway through + // typing throws the typing away. + val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + viewModel.selectSet(GroupRelaySet.General) + assertTrue(viewModel.addRelay(two.url)) + + viewModel.seedWorkingCopy(signed) + + assertEquals( + listOf(one, two), + viewModel.relaysOf(GroupRelaySet.General).map { it.url }, + ) + } +} From e74592a48ea7bfe1d21d0b9f5905fbc025119d88 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Thu, 10 Sep 2026 11:46:06 +0200 Subject: [PATCH 09/71] feat(groups): what the group says out loud, and the one arm that must not swallow a message `4c0ed0c1` gave a group a name and an address on nostr and nothing to say from either. This is the third statement that identity is made of: kind:1s authored by the room's own key, listed under the relay lists on the group's screen, with a composer behind "Add post" that proposes the next one to the quorum. The section sits under Relays and above Library on purpose. The profile says who the group is, the relay lists say where to find it, and these are what a reader would find there -- so they finish the identity block, and the library below them is the group's *work* rather than its voice. **A post is the group speaking; the transcript is a member speaking.** Those are different things and the room already had the second. A room's messages are `ChatEvent` kind:9 sent by whoever sent them; a post is kind:1 authored by the room's id, so any reader can check the group said it and that no single member could have made it say so. The composer's byline is the whole of that design: every other composer in this app puts the writer's name over the draft because the writer is the author, and doing that here would have a member writing what looks like their own note and finding the group had said it. The byline is the group's profile picture and name, shown before a word is typed. **The kind:1 arm in `applyInnerEvent` is the dangerous one in this change, and it is dangerous in the opposite direction to the last two.** Every kind the group signs needs an arm there or a signed post lands in the transcript as raw JSON. But kind:1 is not like kind:0 or a relay list: those are identity plumbing nobody sends into a room on purpose, so the arms added in `4c0ed0c1` return null for one that is not the group's and the event silently vanishes. A kind:1 is *content*. Somebody meant it, and it has rendered as an `unsupported` event since long before any of this existed. So this arm verifies, and on failure **falls through to `unsupported` rather than dropping the event** -- which required naming that branch as a local `fun unsupported()` so one arm can reach it deliberately instead of only falling off the end of the `when`. Getting this wrong would have been this change quietly deleting messages it has no business touching, and it would have looked like nothing at all. **Verified, not merely attributed** -- the same rule the other two arms ended up at. A rumor carries an empty signature and any pubkey its sender likes, so an author check alone would let one member write "the group posted in its own name" into the room's own transcript. `GroupSignedEvent.verifies` is what makes the line mean what it says. **Posts accumulate; everything else in this feature replaces.** kind:1 is not replaceable, so a second post stands beside the first rather than superseding it, and every "newest wins" the profile and the relay lists are built on is wrong for these. `newestFirstAmong` sorts rather than reduces, and `GroupPostTest` asserts three posts survive all three orderings a query could hand them over in -- a reading that kept only the newest would silently delete a group's entire history the first time it posted twice, and would look like a feature until somebody noticed. **Blank posts are refused twice.** The composer will not propose one, because a quorum signing an empty note puts a post on the record that renders as nothing, cannot be told from a broken one, and -- kind:1 not being replaceable -- cannot be un-said. And `newestFirstAmong` drops one that reaches it anyway, since anything blank arriving from outside this app is in exactly the same position. **A reply is marked as one.** Nothing here writes replies -- `GroupPost.template` builds a bare note, no `e` tags, no mentions, no NIP-14 subject, because every tag `TextNoteEvent.build` can add describes a relationship to something else that a group's first way of posting does not have and should not guess at. The mark is for an event that arrived some other way: drawing a reply as an announcement would put half a conversation on a group's page with nothing saying it was half. **The card shows the whole note.** A post is short by construction and it is the one thing on that screen which exists to be *read*; truncating it would mean tapping through to see a paragraph. The composer grows into the screen for the same reason -- a three-line box that hides what was written two sentences ago is a box nobody proofreads in. **No new `Post` row, for the reason there is no `Profile` row.** `Post` hangs off both `NostrEvent` and `Profile` by foreign key and a group has neither, so this reads off `GroupSignedEvent` like the profile and the relay lists. `FrostSigningManager.complete` already files every signed event before applying it, so no table, no migration, and one more kind-scoped query. **Reading a post needs no share of the key; proposing one does.** A post is the one statement here meant for people who were not in the room, so a member holding no share reads the section like anybody else and simply gets no "Add post". Both gates are `canEditNostrProfile`, which is `canSign` read once for what is now four entry points. **And the caveat that bites hardest here: nothing has reached a relay.** `FrostSigningManager.complete` puts nothing on the wire, so a group's posts are visible to its members and to nobody else -- for a profile that is an inconvenience, for a post it is most of the point. The relay lists directly above the section say where these should go and nothing yet sends them. Stated at the top of `GroupPost` in those terms rather than the neutral ones the other two readers use. `TYPE_GROUP_POST_SIGNED` joins `GROUP_IDENTITY_TYPES`, so the transcript draws it as a `RitualNotice` like the other two -- nobody said it, the group signed it. The line names the post rather than quoting it: the post itself is on the group's screen where it can be read whole, and a quote would be the same words twice with the second copy unable to say who agreed to them. 10 new cases in `GroupPostTest`, signed with real FROST quorums through the same shape `FrostSigningManager.advance` runs, and three more in the section layout test -- ordering on screen, the empty state, and a member with no share reading posts they cannot add to. 1226 tests pass -- 787 in `:composeApp:jvmTest`, 439 in `:composeApp:testDebugUnitTest`, 13 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@334e6dd1cbad580c9d3753c0427388e557040fa5 --- .../composeResources/values/strings.xml | 11 + .../compose/database/model/ChatMessage.kt | 78 +++- .../repository/DatabaseChatRepository.kt | 13 + .../press/mantra/compose/nostr/GroupPost.kt | 121 +++++++ .../compose/repository/ChatRepository.kt | 13 + .../ui/composable/AddGroupPostScreen.kt | 333 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 145 ++++++++ .../ui/composable/navigation/MantraNavHost.kt | 22 ++ .../navigation/routes/AddGroupPostRoute.kt | 10 + .../ui/view/model/AddGroupPostViewModel.kt | 154 ++++++++ .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../ui/view/state/AddGroupPostUIState.kt | 33 ++ .../ui/view/state/ChatRoomDetailUIState.kt | 10 + .../mantra/compose/nostr/GroupPostTest.kt | 307 ++++++++++++++++ .../GroupNostrProfileSectionJvmTest.kt | 75 +++- 15 files changed, 1311 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 087a4478..e415143a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -446,4 +446,15 @@ Relays the group will not talk to. A group signs and cannot decrypt, so every list here is public — including the blocked one, which most clients keep private. Signed %1$s + Posts + Add post + Propose post + This group hasn't posted anything yet. + What should the group say? + The group signs a post, so it takes a quorum. It goes out in the group's name, not yours. + This group has no shared key, so it cannot sign a post. Run a shared key ceremony first. + A post needs something to say. + Couldn't ask the group to sign this post. + Posted %1$s + A reply diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 4de3f97f..020b3c39 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -21,6 +21,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -422,9 +423,21 @@ data class ChatMessage( * renders as a chat bubble, silently, looking exactly like somebody having * said "The group is now called Translation collective". */ + /** + * The group signed something to say publicly, in its own name. + * + * A system line rather than a bubble, like the other two -- nobody said it, + * the group signed it -- and the line names the post rather than repeating + * it, because the post itself is on the group's screen where it can be read + * whole. A quote here would be the same words twice with the second copy + * unable to say who agreed to them. + */ + const val TYPE_GROUP_POST_SIGNED = "groupPostSigned" + val GROUP_IDENTITY_TYPES = setOf( TYPE_GROUP_PROFILE_SIGNED, TYPE_GROUP_RELAYS_SIGNED, + TYPE_GROUP_POST_SIGNED, ) /** @@ -977,6 +990,22 @@ data class ChatMessage( createdAt: Instant, groupSignedEventId: HexKey? = null, ): ChatMessage? { + // The last resort, named because one arm reaches it deliberately rather + // than by falling off the end: a kind:1 the room did not sign is a + // member's own note travelling through the room, and it has always + // rendered as this. See the `TextNoteEvent` arm. + fun unsupported() = ChatMessage( + giftWrapPayloadId = null, + messageType = "unsupported", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = event.toJson(), + ) + return when (event.kind) { ChatEvent.KIND -> { ChatMessage( @@ -1429,6 +1458,41 @@ data class ChatMessage( ) } + // The group saying something publicly, in its own name -- which + // is a different thing from a member saying something in the room. + // A room's messages are `ChatEvent`s; this is a kind:1 authored by + // the room's own key, so any reader can check the group said it and + // that no single member could have. + // + // Verified like the arms below and for the same reason: a rumor + // carries an empty signature and any pubkey its sender likes, so an + // author check alone would let one member put words in the group's + // mouth in its own transcript. + // + // **Unlike those arms, a kind:1 that fails falls through to + // `unsupported` rather than vanishing.** A member's kind:0 or relay + // list passing through a room is identity plumbing nobody sent on + // purpose and silence is the right treatment; a kind:1 is content, + // somebody meant it, and it has rendered as an unsupported event + // since before any of this existed. Dropping it here would be this + // change quietly deleting messages it has no business touching. + TextNoteEvent.KIND -> { + val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) + if (!signed.verifies()) return unsupported() + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_POST_SIGNED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group posted in its own name" + ) + } + // The group saying where it lives on nostr: which relays carry // its work, take its messages, answer a search of it, and which it // will not talk to. @@ -1471,19 +1535,7 @@ data class ChatMessage( ) } - else -> { - ChatMessage( - giftWrapPayloadId = null, - messageType = "unsupported", - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = event.toJson(), - ) - } + else -> unsupported() } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 25a629df..277f3755 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.database.model.ChatMessage @@ -29,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -157,6 +159,17 @@ class DatabaseChatRepository( GroupRelayList.allAmong(emptyList(), chatRoomId) } + override suspend fun groupPosts(chatRoomId: String): List = try { + GroupPost.newestFirstAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, TextNoteEvent.KIND), + chatRoomId = chatRoomId, + ) + } catch (e: Throwable) { + logger.e("Error reading the posts of $chatRoomId", e) + emptyList() + } + override suspend fun canSign(chatRoomId: String): Boolean = try { FrostSigningManager.canSign(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt new file mode 100644 index 00000000..adbe327b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt @@ -0,0 +1,121 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlin.time.Instant + +/** + * A kind:1 the group signed: something it said publicly, as itself. + * + * The room's id is the pubkey it signs as, so a note authored by that key is the + * *group* posting rather than a member posting in the group. Those are different + * things and the room already has the second one -- the transcript. This is the + * first: a statement the group put a quorum's signature to, which any reader can + * check came from the group and not from whoever typed it. + * + * ### Why this is not a `Post` row + * + * `Post` hangs off both `NostrEvent` and `Profile` by foreign key, and a group has + * neither: a group-signed event is not a `NostrEvent` -- see `GroupNostrProfile`, + * which gives the reasoning at length -- and the group has no `Profile` row for the + * same reason. So a group's posts live where the group's other signed statements + * live, and this is the reading of them. + * + * ### Nothing here has reached a relay + * + * The same caveat `GroupNostrProfile` and `GroupRelayList` carry, and it bites + * hardest here: a post is the one kind of statement whose entire purpose is to be + * read by people outside the group. `FrostSigningManager.complete` applies signed + * events locally and puts nothing on the wire, so for now a group's posts are + * visible to its members and to nobody else. The relay lists beside them say where + * they should go; sending them is a job that does not exist yet. + */ +data class GroupPost( + /** The group's statement, whole, with the signature that makes it one. */ + val signedEvent: GroupSignedEvent, +) { + /** The group's nostr identity, which for a derived room is also its id. */ + val publicKey: HexKey get() = signedEvent.publicKey + + /** When the group signed it, which is the order posts are read in. */ + val postedAt: Instant get() = signedEvent.createdAt + + /** What the group said. */ + val content: String get() = signedEvent.content + + /** + * Whether this is the group speaking rather than answering. + * + * A kind:1 with no `e` tag starts a thread; one with them is a reply. Nothing + * here writes a reply yet -- the composer builds a bare note -- so this exists + * to keep an imported or chronicled reply from being drawn as an announcement. + */ + fun isNewThread(): Boolean = asTextNoteEvent().isNewThread() + + /** The event as the group signed it. */ + fun asTextNoteEvent(): TextNoteEvent = signedEvent.toEvent().let { event -> + TextNoteEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + } + + companion object { + /** + * The reading of one signed event, or null when it is not one of these. + * + * The same gate the profile and the relay lists use: the wrong kind, or a + * signature that does not check out as [chatRoomId]'s, and a caller gets + * nothing. A kind:1 a member sent into the room is that member's note, not + * the group's, and fails on the author. + */ + fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupPost? { + if (signedEvent.kind != TextNoteEvent.KIND) return null + if (!signedEvent.verifies()) return null + + return GroupPost(signedEvent) + } + + /** + * Every post [chatRoomId] signed among [signedEvents], newest first. + * + * Newest first because that is how a feed is read, and the id breaks a tie + * so two devices holding the same posts show them in the same order. Unlike + * a profile or a relay list these accumulate rather than replace: kind:1 is + * not replaceable, so a second post stands beside the first. + * + * Blank posts are dropped. A signed note with nothing in it renders as an + * empty card that cannot be told from a broken one, and the composer will + * not produce one -- so anything that reaches here empty came from + * somewhere this app does not control. + */ + fun newestFirstAmong( + signedEvents: List, + chatRoomId: String, + ): List = + signedEvents + .mapNotNull { of(it, chatRoomId) } + .filter { it.content.isNotBlank() } + .sortedWith(compareByDescending { it.postedAt }.thenByDescending { + it.signedEvent.id + }) + + /** + * The event to ask the group to sign for a post saying [note]. + * + * A bare kind:1: no reply tags, no mentions, no subject. What the group is + * doing here is speaking, and every tag `TextNoteEvent.build` can add + * describes a relationship to something else -- a thread to reply into, a + * pubkey to notify -- which a group's first way of posting does not have + * and should not guess at. + */ + fun template(note: String): EventTemplate = + TextNoteEvent.build(note = note.trim()) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 71fd8fea..9b44ee7c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage @@ -102,6 +103,16 @@ interface ChatRepository { */ suspend fun groupRelayLists(chatRoomId: String): List + /** + * The kind:1s this group signed, newest first. + * + * The group speaking as itself, which is a different thing from a member + * speaking in the room -- the transcript is the second. Unlike the profile and + * the relay lists these accumulate rather than replace, so this is a list and + * an empty one means the group has said nothing publicly. + */ + suspend fun groupPosts(chatRoomId: String): List + /** * Whether this device holds a share of the group's key, and so could take * part in signing for it. @@ -288,6 +299,8 @@ interface ChatRepository { override suspend fun groupRelayLists(chatRoomId: String): List = GroupRelayList.allAmong(emptyList(), chatRoomId) + override suspend fun groupPosts(chatRoomId: String): List = emptyList() + override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun refuseSubgroup( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt new file mode 100644 index 00000000..ac0bdc77 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt @@ -0,0 +1,333 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AddGroupPostViewModel +import press.mantra.compose.ui.view.state.AddGroupPostUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_post_needs_something_to_say +import mantra.composeapp.generated.resources.add_post +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_post +import mantra.composeapp.generated.resources.propose_post +import mantra.composeapp.generated.resources.the_group_signs_a_post_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_post +import mantra.composeapp.generated.resources.what_should_the_group_say +import org.jetbrains.compose.resources.stringResource + +/** + * The draft of something the group would say, in its own name. + * + * Not a message in the room: this is signed by the group's key and read by anybody, + * which is why the byline over the draft is the group's rather than the writer's. + * Whoever types it is proposing words for the group to put its signature to, and + * the words become a post -- attributed to the group and to nobody in particular -- + * only once a quorum has signed. + * + * The byline is the whole of the design here. Every other composer in this app puts + * the author's name over the draft because the author is the person typing; getting + * that wrong on this screen would have a member writing what looks like their own + * note and finding the group had said it. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddGroupPostScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val addGroupPostViewModel: AddGroupPostViewModel = viewModel( + factory = AddGroupPostViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddGroupPostUIState = initialAddGroupPostUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in the click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_post) + val nothingToSay = stringResource(Res.string.a_post_needs_something_to_say) + + ScreenStateTransition(addGroupPostViewModel.addGroupPostUIState) { uiState -> + when (val addGroupPostUIState = uiState) { + is AddGroupPostUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState(message = addGroupPostUIState.message, onRetry = null) + } + + is AddGroupPostUIState.Loaded -> { + val noteFieldState = rememberTextFieldState() + val canSign = addGroupPostUIState.canSign + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + // imePadding: this screen is a text field the size of the screen, + // and without it the software keyboard covers what is being typed. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(text = stringResource(Res.string.add_post)) } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + addGroupPostViewModel.proposePost( + localChatRoom = addGroupPostUIState.localChatRoom, + noteField = noteFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. Nothing has been + // posted yet -- the post appears + // when a quorum signs -- so landing + // on the list it is not in would + // read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // Both stay on the draft. One is an + // empty note and one is a failure to + // propose, and neither is a reason to + // throw away what was written. + onEmpty = { notify(nothingToSay) }, + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon(Icons.Default.Draw, contentDescription = "Propose post") + Text(stringResource(Res.string.propose_post)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Whose name this goes out in, said before a word is typed. + // It is the group's, and it is the one thing about this + // screen a member could reasonably get wrong. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileAvatar( + size = MaterialTheme.spacing.space500, + publicKey = chatRoomId, + picture = addGroupPostUIState.groupNostrProfile?.picture(), + name = addGroupPostUIState.groupNostrProfile?.name() + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + Text( + text = addGroupPostUIState.groupNostrProfile?.name() + ?: addGroupPostUIState.localChatRoom.chatRoom.subject + ?: chatRoomId.take(16), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.the_group_signs_a_post_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_a_post + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = noteFieldState, + // The draft grows into the screen rather than scrolling + // inside three lines: a post is the one thing here + // somebody writes at length, and a composer that hides + // what was written two sentences ago is a composer + // nobody proofreads in. + lineLimits = TextFieldLineLimits.MultiLine(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + placeholder = { + Text(stringResource(Res.string.what_should_the_group_say)) + } + ) + } + } + } + + AddGroupPostUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.add_post), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialAddGroupPostUIState == AddGroupPostUIState.Loading) { + addGroupPostViewModel.initiateAddGroupPost() + } + } +} + +@ConformancePreviews +@Composable +private fun AddGroupPostScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddGroupPostScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialAddGroupPostUIState = AddGroupPostUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // A group that can sign, so the composer renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 6dce2aef..96894ba6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -2,6 +2,7 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks @@ -71,6 +73,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -94,6 +97,11 @@ import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.nostr_profile import mantra.composeapp.generated.resources.relays +import mantra.composeapp.generated.resources.posts +import mantra.composeapp.generated.resources.add_post +import mantra.composeapp.generated.resources.a_reply +import mantra.composeapp.generated.resources.posted_on +import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet import mantra.composeapp.generated.resources.edit_relays import mantra.composeapp.generated.resources.not_set_yet import mantra.composeapp.generated.resources.no_relays_in_this_list @@ -117,6 +125,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRout import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import mantra.composeapp.generated.resources.Res @@ -448,6 +457,70 @@ fun ChatRoomDetailScreen( } } + // Under the relay lists, because these are what the + // lists are *for*: the profile says who the group + // is, the relays say where to find it, and these are + // what a reader would find there. Still above the + // library, which is the group's work rather than its + // voice. + item { + Text( + text = stringResource(Res.string.posts), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.groupPosts.isEmpty()) { + item { + Text( + stringResource( + Res.string.this_group_has_not_posted_anything_yet + ) + ) + } + } else { + items( + items = chatRoomDetailUIState.groupPosts, + key = { post -> post.signedEvent.id } + ) { post -> + GroupPostCard( + post = post, + publicKey = chatRoomId, + groupNostrProfile = + chatRoomDetailUIState.groupNostrProfile + ) + } + } + + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + AddGroupPostRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Campaign, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.add_post)) + } + } + } + item { HorizontalDivider() } @@ -1324,6 +1397,78 @@ private fun GroupRelaySet.summaryLabel() = when (this) { GroupRelaySet.Blocked -> Res.string.relay_set_blocked } +/** + * One thing the group said, as the group signed it. + * + * The whole note rather than a preview. A post is short by construction -- kind:1 + * with no long-form arm behind it -- and truncating the one kind of content on this + * screen that exists to be *read* would mean opening something to see a paragraph. + * + * **The byline is the group's**, taken from its profile where it has one and from + * the room's own id where it has not. It is worth spelling out because a post is + * the one thing here a reader might otherwise attribute to whichever member + * proposed it -- and the point of signing it as the group is that they cannot. + * + * A reply is marked as one. Nothing in this app writes replies, so the mark is for + * an event that arrived some other way; drawing one as an announcement would put + * half a conversation on a group's page with no sign that it was half. + */ +@Composable +private fun GroupPostCard( + post: GroupPost, + publicKey: HexKey, + groupNostrProfile: GroupNostrProfile? +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ProfileAvatar( + size = MaterialTheme.spacing.space400, + publicKey = publicKey, + picture = groupNostrProfile?.picture(), + name = groupNostrProfile?.name() + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = groupNostrProfile?.name() ?: publicKey.take(16).inComparableGroups(), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource( + Res.string.posted_on, + post.postedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!post.isNewThread()) { + Text( + text = stringResource(Res.string.a_reply), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = post.content, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + /** * One subgroup, as the parent's record and this device's rooms together have it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 7273e49d..d3be59ff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -41,6 +41,7 @@ import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen +import press.mantra.compose.ui.composable.AddGroupPostScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen @@ -125,6 +126,7 @@ import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -1012,6 +1014,26 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddGroupPostScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the composer so back returns to the group rather + // than to a draft whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt new file mode 100644 index 00000000..7400f78f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddGroupPostRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt new file mode 100644 index 00000000..a4fd0d0e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt @@ -0,0 +1,154 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.AddGroupPostUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Asks the group to say something publicly, in its own name. + * + * The post is not written here and does not exist yet. What goes out is a proposal + * to sign one, and the post appears -- on every member's device at once, authored by + * the room's own key rather than by whoever typed it -- when enough members have + * signed. That author is the room's id: signing runs at the path the room was + * derived at, so a post says which group made it simply by being signed. + * + * That is the difference from a message in the room. A message is a member speaking + * where the group can hear; this is the group speaking where anyone can, and it + * takes a quorum to say. + */ +class AddGroupPostViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddGroupPostUIState: AddGroupPostUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addGroupPostUIState: AddGroupPostUIState by mutableStateOf(initialAddGroupPostUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddGroupPost() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addGroupPostUIState = if (localChatRoom == null) { + AddGroupPostUIState.Error("Couldn't find the chat room") + } else { + AddGroupPostUIState.Loaded( + localChatRoom = localChatRoom, + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + // A NIP-17 room has no key of its own to sign as, so it has no + // identity to post under -- the same condition the group detail + // screen gates the entry point on. + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Opens a session over one kind:1 authored by the group. + * + * Refuses a blank draft rather than proposing it: a quorum signing an empty note + * would put a post on the record that renders as nothing and cannot be told from + * a broken one -- and kind:1 is not replaceable, so there is no un-saying it. + * `GroupPost.newestFirstAmong` drops blanks on the way back out too, which is + * the second line of the same defence. + * + * The draft is not cleared on success. This screen is replaced by the signing + * session, and on the failure path the words are the thing worth keeping. + */ + fun proposePost( + localChatRoom: LocalChatRoom, + noteField: TextFieldState, + onSuccess: (sessionId: String) -> Unit, + onEmpty: () -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. Posts accumulate rather than replace, so a + // second session over the same words is a second post on the record forever. + if (isActionPending.value) return + + val note = noteField.text.toString().trim() + if (note.isBlank()) { + onEmpty.invoke() + return + } + + isActionPending.value = true + + val template = GroupPost.template(note) + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = template.kind, + tags = template.tags, + content = template.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a post for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddGroupPostViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddGroupPostViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddGroupPostUIState = initialAddGroupPostUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 9a807bf7..144507b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -90,6 +90,7 @@ class ChatRoomDetailViewModel( parentChatRoomId = chatRepository.parentOf(chatRoomId), groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), groupRelayLists = chatRepository.groupRelayLists(chatRoomId), + groupPosts = chatRepository.groupPosts(chatRoomId), canEditNostrProfile = canSign, canAddSubgroup = canSign, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt new file mode 100644 index 00000000..4bcbffb3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt @@ -0,0 +1,33 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile + +sealed interface AddGroupPostUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * Who the group says it is, for the byline over the draft. + * + * A post is signed by the group and read as the group, so the composer + * shows whose voice is being written in -- which is not the member typing. + * Null in a group that has not described itself, and the byline falls back + * to the room. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the draft writable and the button inert, for the same reason + * the other two editors do: reading and composing cost nothing, and only a + * share-holder can open the session that would sign it. + */ + val canSign: Boolean = false, + ): AddGroupPostUIState + + data class Error( + val message: String + ): AddGroupPostUIState + + data object Loading: AddGroupPostUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 394336cd..e18535f4 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList sealed interface ChatRoomDetailUIState { @@ -74,6 +75,15 @@ sealed interface ChatRoomDetailUIState { * four entries, some of them unsigned; see `ChatRepository.groupRelayLists`. */ val groupRelayLists: List = emptyList(), + /** + * What the group has said publicly, as itself, newest first. + * + * Under the relay lists because it is the thing they are *for*: the profile + * says who the group is, the relay lists say where to find it, and these + * are what a reader would find there. Empty in a group that has said + * nothing, which is every group until somebody proposes one. + */ + val groupPosts: List = emptyList(), /** * Whether this device could sign a profile for the group. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt new file mode 100644 index 00000000..53fc1c73 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt @@ -0,0 +1,307 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What may be shown as something the group said. + * + * The reading gate is `GroupNostrProfileTest`'s -- the room has to be the author and + * the signature has to be the room's -- and it matters more here than anywhere else + * in this feature. A profile read wrong shows the wrong name; a post read wrong puts + * words in a group's mouth under a byline saying a quorum agreed to them, on a page + * whose whole purpose is to be read by people who were not there. + * + * The ordering half is the other thing worth pinning. Posts are the one statement + * here that **accumulates**: kind:1 is not replaceable, so a second post stands + * beside the first rather than replacing it, and everything the profile and the + * relay lists do with "newest wins" is wrong for these. + */ +class GroupPostTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the posts signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + @Test + fun `a kind one the room signed is a post by the group`() { + val post = GroupPost.of( + signedEvent = signed("We have finished the first chapter."), + chatRoomId = chatRoomId + ) + + assertEquals("We have finished the first chapter.", post?.content) + assertEquals(chatRoomId, post?.publicKey) + assertTrue(post?.isNewThread() == true) + } + + @Test + fun `a kind one another group signed is not this group's post`() { + // A real quorum and a real signature by a group with no standing to speak + // for this one. The row names this room because that is where it was filed; + // the author is what decides whose words they are. + val stranger = GroupSignedEvent.fromEvent( + event = noteEvent("Said by somebody else entirely.", material = strangerMaterial), + chatRoomId = chatRoomId + ) + + assertNull(GroupPost.of(signedEvent = stranger, chatRoomId = chatRoomId)) + } + + @Test + fun `a kind one the room did not sign is not a post`() { + // Authored by the room and signed by somebody else: the shape a member + // putting words in the group's mouth would produce, and the reason the + // reading verifies rather than trusting the author field. + assertNull( + GroupPost.of( + signedEvent = signed("The group has decided to disband.", signer = strangerMaterial), + chatRoomId = chatRoomId + ) + ) + + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupPost.of( + signedEvent = signed("Anything at all", signature = rubbish), + chatRoomId = chatRoomId + ), + "a post signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `an event of another kind is not a post`() { + assertNull( + GroupPost.of( + signedEvent = signed("Hello").copy(kind = 30023), + chatRoomId = chatRoomId + ) + ) + } + + @Test + fun `posts accumulate rather than replace, newest first`() { + // The property that separates these from the profile and the relay lists. + // A reading that kept only the newest would silently delete a group's whole + // history the first time it posted twice. + val first = signed("First", createdAt = 1_700_000_000) + val second = signed("Second", createdAt = 1_700_000_900) + val third = signed("Third", createdAt = 1_700_001_800) + + listOf( + listOf(first, second, third), + listOf(third, second, first), + listOf(second, third, first), + ).forEach { events -> + assertEquals( + listOf("Third", "Second", "First"), + GroupPost.newestFirstAmong(events, chatRoomId).map { it.content }, + "posts came back in the wrong order, or one of them went missing" + ) + } + } + + @Test + fun `two posts signed in the same second resolve the same way on every device`() { + // Two devices reading the same posts in whatever order the query hands them + // over have to show the same feed. A tie on the timestamp breaks on the id, + // which both agree on because it is a hash of the event. + val one = signed("One", createdAt = 1_700_000_000) + val other = signed("Other", createdAt = 1_700_000_000) + + val expected = GroupPost.newestFirstAmong(listOf(one, other), chatRoomId).map { it.content } + + assertEquals( + expected, + GroupPost.newestFirstAmong(listOf(other, one), chatRoomId).map { it.content }, + "a tie on the timestamp was broken differently by the two orderings" + ) + assertEquals( + if (one.id > other.id) listOf("One", "Other") else listOf("Other", "One"), + expected, + "the tie should break on the id, which is the only thing both devices share" + ) + } + + @Test + fun `a blank post is dropped rather than drawn as an empty card`() { + // The composer will not produce one, so anything reaching here empty came + // from somewhere this app does not control -- and an empty signed card + // cannot be told from a broken one. + listOf("", " ", "\n\n").forEach { blank -> + assertEquals( + emptyList(), + GroupPost.newestFirstAmong(listOf(signed(blank)), chatRoomId), + "a post saying \"$blank\" was shown" + ) + } + } + + @Test + fun `a reply is marked as one`() { + // Nothing here writes replies, so this is about an event that arrived some + // other way. Drawing one as an announcement would put half a conversation on + // a group's page with no sign that it was half. + val reply = GroupPost.of( + signedEvent = signed( + "Yes, agreed.", + tags = arrayOf(arrayOf("e", "b".repeat(64), "", "reply")) + ), + chatRoomId = chatRoomId + ) + + assertFalse(reply?.isNewThread() == true) + } + + @Test + fun `the composer builds a bare note and trims it`() { + // No reply tags, no mentions, no subject: every tag `TextNoteEvent.build` + // can add describes a relationship to something else, which a group's first + // way of posting does not have and should not guess at. + val template = GroupPost.template(" Something the group would say. ") + + assertEquals(TextNoteEvent.KIND, template.kind) + assertEquals("Something the group would say.", template.content) + assertEquals(emptyList(), template.tags.toList()) + } + + @Test + fun `a post the composer built reads back as the group said it`() { + val template = GroupPost.template("We have finished the first chapter.") + val post = GroupPost.of( + signedEvent = signed(template.content, tags = template.tags), + chatRoomId = chatRoomId + ) + + assertEquals("We have finished the first chapter.", post?.content) + assertTrue(post?.isNewThread() == true) + } + + /** A post as `FrostSigningManager.complete` files one. */ + private fun signed( + note: String, + tags: Array> = emptyArray(), + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = noteEvent(note, tags, createdAt, material, signer, signature), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun noteEvent( + note: String, + tags: Array> = emptyArray(), + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = tags, + content = note + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = tags, + content = note, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index 1d0d4eb4..fdf7711e 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -16,6 +16,7 @@ import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelay import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet @@ -27,6 +28,7 @@ import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import kotlin.test.Test import kotlin.test.assertTrue @@ -135,6 +137,58 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Edit relays").assertDoesNotExist() } + @Test + fun `a member holding no share of the key reads the posts and is offered no composer`() = + render( + state( + groupNostrProfile = profile, + posts = listOf(post("Something the group said", 1_700_000_000)), + canEditNostrProfile = false + ) + ) { + // A post is the one thing here that is meant to be read by people who + // were not in the room, so a member without a share reads it like + // anybody else and simply cannot propose the next one. + onNodeWithText("Something the group said").assertIsDisplayed() + onNodeWithText("Add post").assertDoesNotExist() + } + + @Test + fun `the posts sit under the relay lists and above the library`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000)) + ) + ) { + val relays = onNodeWithText("Relays").getBoundsInRoot().top + val posts = onNodeWithText("Posts").getBoundsInRoot().top + val library = onNodeWithText("Library").getBoundsInRoot().top + + assertTrue(relays < posts, "the posts climbed above the relay lists") + assertTrue(posts < library, "the posts fell below the library") + + // Newest first, and whole rather than previewed -- a post is the one thing + // on this screen that exists to be read. + val newest = onNodeWithText("The second thing").getBoundsInRoot().top + val oldest = onNodeWithText("The first thing").getBoundsInRoot().top + assertTrue(newest < oldest, "the posts were drawn oldest first") + + // Under the group's name, not the member's. The whole point of signing a + // post as the group is that no member can be blamed for it. + onAllNodesWithText("Translation collective").assertCountEquals(3) + onNodeWithText("Add post").assertIsDisplayed() + } + + @Test + fun `a group that has posted nothing says so, and offers to post`() = render( + state(groupNostrProfile = profile, relayLists = signedRelayLists()) + ) { + onNodeWithText("Posts").assertIsDisplayed() + onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed() + onNodeWithText("Add post").assertIsDisplayed() + } + @Test fun `a group that has said nothing says so, and offers to say something`() = render( state(groupNostrProfile = null) @@ -164,10 +218,12 @@ class GroupNostrProfileSectionJvmTest { .assertDoesNotExist() onNodeWithText("Edit Nostr profile").assertDoesNotExist() - // The relay lists go with it: they describe a nostr identity this room - // does not have. + // The relay lists and the posts go with it: all three describe a nostr + // identity this room does not have. onNodeWithText("Relays").assertDoesNotExist() onNodeWithText("Edit relays").assertDoesNotExist() + onNodeWithText("Posts").assertDoesNotExist() + onNodeWithText("Add post").assertDoesNotExist() // The rest of the screen is untouched, so the section's absence is an // absence rather than a screen that failed to draw. @@ -207,9 +263,23 @@ class GroupNostrProfileSectionJvmTest { createdAt = Instant.fromEpochSeconds(1_700_000_000) ) + private fun post(content: String, createdAt: Long) = GroupPost( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'b'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = content, + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ) + ) + private fun state( groupNostrProfile: GroupNostrProfile?, relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), + posts: List = emptyList(), canEditNostrProfile: Boolean = true, mlsGroupState: String? = "state" ) = ChatRoomDetailUIState.Loaded( @@ -238,6 +308,7 @@ class GroupNostrProfileSectionJvmTest { ), groupNostrProfile = groupNostrProfile, groupRelayLists = relayLists, + groupPosts = posts, canEditNostrProfile = canEditNostrProfile ) From f8e5d3610d2493e8db718904cfc0b81045c5f093 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 11 Sep 2026 15:57:41 +0200 Subject: [PATCH 10/71] feat(groups): the lists a group curates, and the one thing an edit may not change `334e6dd1` gave a group a way to speak in its own name. This is the way it asks: kind 31889, a *curated schema event*, signed by the room's own key -- the definition of a list anyone may suggest entries to and only that key may accept them into. The protocol is the curated-list NIP written up in the bitcoin.mov repo (`docs/NIP.md` there, with `curated-schema-events.md` as the guided tour), and this is its first half in this app: the schema. Suggestions (31888) and canonical entries (31890) reply to it and are not read or written here yet. The section sits under Posts and closes the nostr-identity block, above Subgroups. The profile says who the group is, the relay lists say where to find it, the posts are what it says there -- and a schema is the other thing it publishes for strangers to answer. A post is the group speaking; a schema is the group asking, for entries to a list it will then curate by quorum under the same key. Everything in that block is signed by that key and read by people who were never in the room, and the divider that used to close it after the posts now closes it after these. **The protocol is ported tag for tag from the reference module, and the fixture is the NIP's own example.** `nostr/curated/CuratedSchemaEvent` reads and writes the event; `CuratedSchema`, `CuratedField` and `CuratedFieldConfig` are what the tags mean. The point of publishing a schema is that another client can drive its form from it, so the one property worth pinning is that what bitcoin.mov published reads here and what this app signs would read there. `CuratedSchemaEventTest` parses the bitcoin.mov schema from the NIP verbatim -- nine `field` tags, the `require-any`, the domain, the relay -- checks every field of it, and round-trips it through `template` back to an equal schema. The field tag's seventh position is a JSON blob, and a key this app does not know is kept in `CuratedFieldConfig.others` and written back, because other clients legitimately add their own and an edit here must not strip what one of them meant. **Rejected rather than repaired, with the one repair the NIP requires.** A schema is what suggestions are checked against, so a reader that patched a broken one would be accepting entries against a form nobody signed. `CuratedSchema.problems` is the NIP's rejection list and nothing more -- a missing identifier, title, name or description, an over-long one, a visibility that is absent or not one of the three words, no `field` tags at all, a picture that is not https, a domain that is not a hostname, a relay that is not a relay -- and it is the *same* list whether the schema was typed into the editor or read off a relay: the editor refuses to propose what a reader would refuse to show. The exception is `normalized`, which forces a field writing to `d` and one writing to `title` into place, both required, because that is the rule the NIP states so that no schema, wherever it came from, can talk a client into accepting untitled or unaddressable entries. The "no fields" check runs before that repair, on what the publisher said. **Visibility is never guessed, so it is nullable.** A list that does not say who may suggest to it is not one a client should act on, and the reference makes the same point by reporting a missing visibility as a violation rather than defaulting it. An enum with no "unknown" arm cannot carry that, so `CuratedSchema.visibility` is null for a schema that does not say, `VisibilityMissing` is the problem it raises, and `template` writes no `visibility` tag for a null one -- which every reader, this one included, then refuses. The editor never produces one; only an event off a relay can be missing it. **One per identifier, newest wins.** The third ordering in this family. A profile is one replaceable event and the newest wins; posts are not replaceable and accumulate; a schema is addressable, so a group may curate several lists and each is its own coordinate `31889::`. `GroupCuratedSchema.newestPerListAmong` groups by identifier, keeps the newest within each -- an edit is a newer event under the same `d` -- and orders the lists most recently signed first, with the event id breaking every tie so two devices reading the same events in different orders agree. **An edit keeps the identifier, whatever the field says.** The identifier is the coordinate every suggestion to the list replies to. Changing it would not edit the list but start a second one with an empty queue and orphan the first's, so the identifier field is read-only on an edit, says why under itself, and `proposeSchema` takes it from the existing schema rather than from the field even so -- a screen not drawing something is not a guard. A new list is "Add schema" on the group's screen, which is why the section has both a per-card way in and a button: each schema is edited on its own. **A new list starts filled in, and with the group's own relays.** The two mandatory fields are put on the form before anything is typed, rather than left for `normalized` to add at signing time, because a form that showed an empty list and then signed two fields would be lying about what it proposed. The relays are seeded from the group's NIP-65 write relays -- where its canonical entries would be read from -- falling back to `GroupRelaySet.General.defaults()`, which is this build's own relay and the same thing a group opening the relay editor for the first time is shown. A schema naming no relays is one whose suggestions could go anywhere, so the seed is worth getting right; the NIP says the same, and a client is allowed to fall back to its own list only when the schema names none. **What the editor lets a group sign is held to the app's rule, not the NIP's.** A `relay` tag may be `ws://` per the NIP and a schema read off a relay is accepted with one; a relay *added here* goes through `GroupRelaySet.relayUrlOrNull` -- `wss://`, not this machine -- because the group is about to put a quorum's signature on it for strangers to publish to, which is exactly the argument that rule was written for. The duplicate check compares normalized forms, since a relay signed into an existing schema is kept as the group wrote it and the normalizer adds a trailing slash: the same host with and without one is one relay. The first version compared strings and would have let the second copy in; `EditGroupCuratedSchemaViewModelJvmTest` pins it. Suggesters -- the `p` tags a closed or private list admits -- are accepted as an npub (with or without `nostr:`) or 64 hex characters and nothing else, and only offered when the visibility gives them something to be. **A field is edited on a sheet of its own, and a mandatory one cannot stop being one.** A field has a dozen settings, and thirteen of them inline would be a screen nobody could find anything on. The sheet offers the positional parts first -- name, label, placeholder, type, required -- and then only the config keys the chosen type gives a meaning to: `min` and a numeric `max` for the three numeric types, `options` for an enum, `https` for a url. A setting the event gives no meaning to would be written into the schema and mean nothing, so when the type changes away from one it is not kept. For a field writing to `d` or `title` the tag is pinned and the requirement is on and disabled, because `normalized` would only put the field back if the sheet let it be moved or relaxed, and offering a change the event will not carry is worse than not offering it. A field name is one word and belongs to one field, and a rename follows through to the `require-any` rules that named it, since a rule naming a field that no longer exists would silently never be satisfied. The rules themselves are a chip per field, selected for the ones in the rule; one with fewer than two names says nothing and is dropped from the draft rather than refused. **The transcript arm returns null rather than `unsupported`.** The opposite call to the kind:1 arm, for the reason that arm gives: a kind:1 is content somebody meant, and a schema rumor from a member -- or one the room signed that no client could act on -- is identity plumbing, the position the kind:0 and relay-list arms are in. Parsed as well as verified, so the transcript and the group's screen agree about which events are lists: `GroupCuratedSchema.of` refuses the same event both places. The line names the list rather than describing the schema, because what a reader of the room needs to know is that the group now curates -- or has changed the form for -- a list called so-and-so, and the fields are on the group's screen. `TYPE_GROUP_SCHEMA_SIGNED` joins `GROUP_IDENTITY_TYPES`, so it is drawn as a `RitualNotice` and previewed like the other three. **The signing screen says what it is.** `ProposedEvent.summarize` gets an arm for 31889, which the last three group features did not add for their kinds. A member deciding whether to sign this is agreeing to take suggestions from whoever the visibility admits and to be the one key that accepts them, and the event's content is only the description -- so "Event of kind 31889" over a sentence would leave them signing a list without being told its name, who may suggest to it, or how much an entry asks for. The summary is those three. **The sheet's switches carry their label as a description.** A screen reader otherwise announces "switch, off" beside a sentence it has already read past. It is also what the layout test reaches the switch by, which is how it was noticed. **The heading is "Curated schemas"**, not the kind's name. The audit enforces sentence case, and the sibling headings name the thing -- "Posts", "Relays" -- rather than the event. **And the caveat that bites hardest here of all: nothing has reached a relay.** The same limit `GroupPost` states, and a schema is the one statement whose *entire* purpose is to be replied to by strangers. Until group-signed events are published, a list defined here has no queue. The relays are signed into the schema ready for that, which is also why the NIP puts them in the event rather than in a config file. 51 new tests. `CuratedSchemaEventTest` (22) works from the NIP's example event: the read, the round trip, the tag order, a config key this app does not know, a malformed blob costing a field its constraints and not its life, every rejection rule, the description falling back to content, the mandatory-field repair, and domain normalization. `GroupCuratedSchemaTest` (8) signs with real FROST quorums through the same shape `FrostSigningManager.advance` runs -- the room's signature reads, a stranger's does not, a forged author does not, and a signed-but-unusable schema is refused -- and pins one-per-identifier ordering and the id tie-break. `EditGroupCuratedSchemaViewModelJvmTest` (14) covers the seed, the identifier lock, the mandatory fields, renames following through to rules, the pubkey and relay rules, the normalized duplicate check, and the button proposing nothing for an untouched schema. `EditGroupCuratedSchemaScreenJvmTest` (4) and three more cases in `GroupNostrProfileSectionJvmTest` cover both editor states, the sheet offering a type its own settings, and the section's place on the screen, its empty state, a member with no share reading schemas they cannot edit, and its absence in a NIP-17 room. 1307 tests pass -- 838 in `:composeApp:jvmTest`, 469 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@930d37c81e7bf67a0ec62d7cf69f596e57af3cb8 --- .../composeResources/values/strings.xml | 102 ++ .../compose/database/model/ChatMessage.kt | 44 + .../repository/DatabaseChatRepository.kt | 13 + .../compose/nostr/GroupCuratedSchema.kt | 107 ++ .../compose/nostr/curated/CuratedSchema.kt | 475 +++++ .../nostr/curated/CuratedSchemaEvent.kt | 206 +++ .../compose/repository/ChatRepository.kt | 16 + .../mantra/compose/text/ProposedEvent.kt | 17 + .../ui/composable/ChatRoomDetailScreen.kt | 179 ++ .../EditGroupCuratedSchemaScreen.kt | 1521 +++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 23 + .../routes/EditGroupCuratedSchemaRoute.kt | 20 + .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../model/EditGroupCuratedSchemaViewModel.kt | 544 ++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 12 + .../state/EditGroupCuratedSchemaUIState.kt | 42 + .../compose/nostr/GroupCuratedSchemaTest.kt | 317 ++++ .../nostr/curated/CuratedSchemaEventTest.kt | 450 +++++ .../EditGroupCuratedSchemaScreenJvmTest.kt | 266 +++ .../GroupNostrProfileSectionJvmTest.kt | 97 +- .../EditGroupCuratedSchemaViewModelJvmTest.kt | 382 +++++ 21 files changed, 4832 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index e415143a..16d2b935 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -457,4 +457,106 @@ Couldn't ask the group to sign this post. Posted %1$s A reply + Curated schemas + This group hasn't published a curated schema yet. A schema defines a list anyone can suggest entries to and only the group can curate. + Add schema + Edit schema + Propose schema + Fields: %1$s + Public + Closed + Private + Anyone may suggest an entry, and anyone may read the list. + Only the group and the suggesters below may suggest. Anyone may read the list. + Only the group and the suggesters below may suggest, and clients show the list to them alone. Relays are open, so this is a convention rather than a secret. + What an entry in this list may contain, and who may suggest one + The group signs a curated schema, so it takes a quorum. Suggestions reply to it, and only the group can accept them into the list. + This group has no shared key, so it cannot sign a curated schema. Run a shared key ceremony first. + Couldn't ask the group to sign this schema. + Identifier + eg. bitcoin.mov + The identifier names this list in every reply to it, so it can't change. Add a schema to start another list. + Title + eg. bitcoin.mov suggestion + Description + eg. Fields for a bitcoin.mov entry: a film, with at least one link to watch it + Visibility + Domain + eg. example.com + A domain replaces the name wherever the list is shown. It's a claim rather than proof, so only set one the group controls. + Fields + Add field + Required + Optional + Derived + At least one of + Add rule + Pick two or more fields, at least one of which an entry must have. A rule with fewer than two is dropped. + Suggesters + Pubkeys besides the group that may suggest to this list. + npub or hex pubkey + Add suggester + That isn't a pubkey. Paste an npub or 64 hex characters. + That pubkey is already a suggester. + Where suggestions and entries are published to and read from. Signed into the schema, so a reply can't be sent anywhere else. + No relays named, so a client will pick its own. + New field + Edit field + Field name + eg. watchUrl + Label + eg. Watch / reference url + Placeholder + eg. https://youtube.com/watch?v=… + Type + An entry must have this field + Writes to tag + Defaults to the field name. "content" means the event's body. + Marker + eg. watch + The tag's third element, which tells fields sharing a tag apart. + Maximum characters + Maximum value + Minimum value + Options + One per line, or separated by commas. + https only + May appear more than once + Pattern + A regular expression the whole value must match. + Filled in by the app, never asked for + Hint + Helper text under the input. + Done + Remove field + A field needs a name. + A field name is one word, with no spaces. + A field named that already exists. + An identifier is required. + The identifier must be at most %1$s characters. + A title is required. + The title must be at most %1$s characters. + A name is required. + The name must be at most %1$s characters. + A description is required. + The description must be at most %1$s characters. + Say who may suggest to this list. + The picture must be an https url. + That isn't a domain name. + A schema needs at least one field. + One of the relays isn't a relay address. + eg. 2100 + eg. 200 + eg. 1900 + eg. movie, documentary, short + eg. [A-Za-z]{2,8} + Text + Long text + Token + Url + Image + One of a list + Year + Duration in seconds + Number diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 020b3c39..539ee32a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -22,7 +22,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import press.mantra.compose.nostr.dkg.DkgRitualEvents @@ -434,10 +436,22 @@ data class ChatMessage( */ const val TYPE_GROUP_POST_SIGNED = "groupPostSigned" + /** + * The group signed the schema of a list it curates. + * + * A system line like the other three, and it names the list rather than + * describing the schema: what a reader of the room needs to know is that + * the group now curates -- or has changed the form for -- a list called + * so-and-so, and the schema itself is on the group's screen where its + * fields can be read. + */ + const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned" + val GROUP_IDENTITY_TYPES = setOf( TYPE_GROUP_PROFILE_SIGNED, TYPE_GROUP_RELAYS_SIGNED, TYPE_GROUP_POST_SIGNED, + TYPE_GROUP_SCHEMA_SIGNED, ) /** @@ -1535,6 +1549,36 @@ data class ChatMessage( ) } + // The group defining a list it curates: the kind 31889 that + // suggestions from anyone reply to and only the group's own key can + // accept into the list. + // + // Verified and parsed, and null on either failing, the way the + // profile arm is. A rumor of this kind from a member is not the + // group curating anything, and a schema the room signed but that no + // client could act on -- no visibility, no fields -- is not worth a + // line saying it did. `GroupCuratedSchema` refuses the same event + // on the group's screen, so the transcript and the screen agree. + CuratedSchemaEvent.KIND -> { + val curated = GroupCuratedSchema.of( + signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId), + chatRoomId = groupId, + ) ?: return null + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_SCHEMA_SIGNED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group signed the curated schema for " + + curated.schema.displayName() + ) + } + else -> unsupported() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 277f3755..01acecfd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -10,7 +10,9 @@ import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.database.model.ChatMessage @@ -170,6 +172,17 @@ class DatabaseChatRepository( emptyList() } + override suspend fun groupCuratedSchemas(chatRoomId: String): List = try { + GroupCuratedSchema.newestPerListAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, CuratedSchemaEvent.KIND), + chatRoomId = chatRoomId, + ) + } catch (e: Throwable) { + logger.e("Error reading the curated schemas of $chatRoomId", e) + emptyList() + } + override suspend fun canSign(chatRoomId: String): Boolean = try { FrostSigningManager.canSign(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt new file mode 100644 index 00000000..40efc723 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt @@ -0,0 +1,107 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.time.Instant + +/** + * A curated schema the group signed: a list it curates, as the group defined it. + * + * Kind 31889 -- see [CuratedSchemaEvent] -- authored by the room's own key. That + * makes the group the curator of the list the schema describes: anyone may reply + * to it with a suggestion, and only an event signed by the same key counts as the + * list itself. For a group that means the same thing everything else on its screen + * means: a quorum decides what goes in. + * + * ### Several per group, one per identifier + * + * Unlike a profile, of which a group has one, a group may curate several lists, + * and each is its own coordinate `31889::`. So the reading is per + * identifier: among the events sharing a `d` the newest wins, the way it does for + * a profile, because the kind is addressable and re-signing one is how a schema + * is edited. Two different identifiers are two lists and both are shown. + * + * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the + * reasons `GroupNostrProfile` gives at length: a group-signed event is not a + * `NostrEvent`, and only an event the room itself signed is the group speaking. + * + * ### Nothing here has reached a relay + * + * The same caveat as the profile, the relay lists and the posts. A schema is the + * one statement here that exists to be *replied to* by strangers, so until it is + * published the list it defines has no queue -- the relays it names say where + * suggestions would go, and nothing yet puts the schema there for them to find. + */ +data class GroupCuratedSchema( + /** The group's statement, whole, with the signature that makes it one. */ + val signedEvent: GroupSignedEvent, + /** What the statement says, already checked and normalized. */ + val schema: CuratedSchema, +) { + /** The group's nostr identity, which for a derived room is also its id. */ + val publicKey: HexKey get() = signedEvent.publicKey + + /** When the group signed it, which is what decides the newest of two. */ + val signedAt: Instant get() = signedEvent.createdAt + + /** The schema's `d`, and the last part of its coordinate. */ + val identifier: String get() = schema.identifier + + /** What a suggestion's `a` root names to reply to this list. */ + fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier) + + companion object { + /** + * The reading of one signed event, or null when it is not one of these. + * + * Three ways to be null and they are all the same refusal: the wrong + * kind, a signature that does not check out as [chatRoomId]'s, or tags + * that do not make a usable schema. A caller gets a schema the group + * really signed and a client could really act on, or gets nothing. + */ + fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupCuratedSchema? { + if (signedEvent.kind != CuratedSchemaEvent.KIND) return null + if (!signedEvent.verifies()) return null + + val schema = CuratedSchemaEvent.parse(signedEvent.toEvent()) ?: return null + + return GroupCuratedSchema(signedEvent = signedEvent, schema = schema) + } + + /** + * The newest schema per identifier [chatRoomId] signed among + * [signedEvents], most recently signed list first. + * + * Newest per identifier because the kind is addressable and an edit is a + * newer event under the same `d`; newest list first so that an edit + * surfaces, and the id breaking every tie so that two devices reading the + * same events in different orders show the same thing. + */ + fun newestPerListAmong( + signedEvents: List, + chatRoomId: String, + ): List = + signedEvents + .mapNotNull { of(it, chatRoomId) } + .groupBy { it.identifier } + .values + .map { versions -> + versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id })) + } + .sortedWith( + compareByDescending { it.signedAt } + .thenByDescending { it.signedEvent.id } + ) + + /** + * The event to ask the group to sign for [schema]. + * + * Built from the schema alone -- see [CuratedSchemaEvent.template] for + * why an edit does not carry the last event's tags forward. + */ + fun template(schema: CuratedSchema): EventTemplate<*> = CuratedSchemaEvent.template(schema) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt new file mode 100644 index 00000000..cc6e7d6d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt @@ -0,0 +1,475 @@ +package press.mantra.compose.nostr.curated + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import press.mantra.compose.network.serialization.CommonJson + +/** + * The definition of a curated list: what an entry may contain, who may suggest + * one, and whose list it is. + * + * This is the document the other two curated kinds are checked against. A + * suggestion (kind 31888) is a reply to it that has to satisfy its fields; a + * canonical entry (kind 31890) is a suggestion its curator republished under + * their own key. The list is the set of canonical entries and the suggestions + * are its queue -- and this is the form both are filled in against, published + * so that another client can render the same form without shipping this code. + * + * The curator's pubkey is the namespace. `31889::` names + * one schema and no other, so nothing global is claimed and nothing can be + * squatted. Here the curator is a group: the schema is signed by the room's own + * key, like its profile and its posts, and it is the group -- by quorum -- that + * accepts a suggestion into the list. + * + * The protocol is written up in `bitcoin.mov/docs/NIP.md`, and the shape here + * follows its reference module tag for tag so that a schema this app signs is + * one that site's form can be driven by, and one it publishes reads here. + * + * ### What is checked, and where + * + * [problems] is the list of reasons a schema is unusable, and it is the same + * list whether the schema was typed into the editor or read off a relay: the + * NIP's rejection rules, no more. A reader rejects rather than repairs -- + * relays carry malformed and hostile events, and a half-parsed form is worse + * than a missing one -- and the editor refuses to propose what a reader would + * refuse to show. + * + * [normalized] is the one repair the NIP requires. Every list has a field + * writing to `d` and one writing to `title`, both required, and a schema that + * omits or relaxes either gets them put back -- so no schema, wherever it came + * from, can talk a client into accepting untitled or unaddressable entries. + */ +data class CuratedSchema( + /** The `d` tag: the schema's identifier, and the last part of its coordinate. */ + val identifier: String, + /** Labels the event -- "bitcoin.mov suggestion". */ + val title: String, + /** The list's identity, what people call it -- "bitcoin.mov". */ + val name: String, + /** What the list is for. Mirrored into the event's content for generic clients. */ + val description: String, + /** + * Who may suggest, or null when the schema does not say. + * + * Null is never defaulted away: a list that does not say who may suggest to + * it is not one a client should act on, so an absent or unrecognised + * visibility is a [CuratedSchemaProblem.VisibilityMissing] rather than a + * public list by accident. The editor always has one; only a schema read off + * a relay can be missing it. + */ + val visibility: CuratedVisibility?, + /** An https image for the list, or null. */ + val picture: String? = null, + /** + * A hostname the list publishes under, or null. Shown in place of [name] + * wherever the list is named, which is why it is a claim worth being careful + * with: anyone can put any domain in a tag. See [displayName]. + */ + val domain: String? = null, + /** The kind suggestions are published under. There is only one. */ + val suggestionKind: Kind = CuratedSchemaEvent.SUGGESTION_KIND, + val fields: List = emptyList(), + /** + * Groups of field names of which at least one must be present on an entry. + * + * How a list says "a watch link or an IMDb link" without making either one + * required. A group of fewer than two names says nothing and is dropped. + */ + val requireAny: List> = emptyList(), + /** + * Pubkeys allowed to suggest on a closed or private list, besides the + * curator. The `p` tags. Meaningless on a public list, where anyone may. + */ + val suggesters: List = emptyList(), + /** + * Where the list lives: the relays suggestions and canonical entries are + * published to and read from. Signed into the event so a client that finds + * the schema anywhere knows where a reply belongs and cannot be sent + * elsewhere by an unsigned config file. + */ + val relays: List = emptyList(), +) { + /** + * What to call the list on screen: the domain when it has one, else the + * name. The domain is the stronger identity because it is the one part of + * this that can be checked against the outside world, NIP-05 style -- and + * nothing here does check it, so it is a label rather than a badge. + */ + fun displayName(): String = domain ?: name + + /** Fields a form should prompt for. Derived ones are filled in by the client. */ + fun formFields(): List = fields.filterNot { it.config.derived == true } + + /** + * The same schema with the two mandatory fields forced into place. + * + * A field writing to `d` and one writing to `title`, both required, in that + * order at the front when they were missing. A schema that already has them + * is unchanged apart from the requirement, so running this twice is running + * it once. + */ + fun normalized(): CuratedSchema { + val fields = fields.toMutableList() + + for ((tag, fallback) in CuratedField.MANDATORY) { + val index = fields.indexOfFirst { it.tag() == tag } + if (index == -1) { + fields.add(0, fallback) + } else if (!fields[index].isRequired) { + fields[index] = fields[index].copy(isRequired = true) + } + } + + return copy(fields = fields) + } + + /** + * Every reason this is not a usable schema, or nothing. + * + * The NIP's rejection rules and only those: a client MUST refuse a schema + * missing its identifier, title, name, description or visibility, one whose + * visibility is not one of the three words, one with no fields, and one + * whose picture is not https, whose domain is not a hostname, or whose relay + * is not a relay. The length caps are the reference module's, so a schema + * this app signs is one that site accepts. + * + * Checked on the schema as written, before [normalized] -- "no fields" is + * about what the publisher said, not about what a reader would put back. + */ + fun problems(): List = buildList { + fun text(value: String, cap: Int, missing: CuratedSchemaProblem, tooLong: CuratedSchemaProblem) { + if (value.isBlank()) add(missing) else if (value.length > cap) add(tooLong) + } + + text(identifier, CuratedSchemaEvent.MAX_IDENTIFIER, CuratedSchemaProblem.IdentifierMissing, CuratedSchemaProblem.IdentifierTooLong) + text(title, CuratedSchemaEvent.MAX_TITLE, CuratedSchemaProblem.TitleMissing, CuratedSchemaProblem.TitleTooLong) + text(name, CuratedSchemaEvent.MAX_NAME, CuratedSchemaProblem.NameMissing, CuratedSchemaProblem.NameTooLong) + text(description, CuratedSchemaEvent.MAX_DESCRIPTION, CuratedSchemaProblem.DescriptionMissing, CuratedSchemaProblem.DescriptionTooLong) + + if (visibility == null) add(CuratedSchemaProblem.VisibilityMissing) + + picture?.let { + if (!CuratedSchemaEvent.isHttpsUrl(it) || it.length > CuratedSchemaEvent.MAX_URL) { + add(CuratedSchemaProblem.PictureNotHttps) + } + } + + domain?.let { + if (!CuratedSchemaEvent.isDomain(it)) add(CuratedSchemaProblem.DomainInvalid) + } + + if (fields.isEmpty()) add(CuratedSchemaProblem.NoFields) + + if (relays.any { !CuratedSchemaEvent.isRelayUrl(it) }) add(CuratedSchemaProblem.RelayInvalid) + } + + fun isUsable(): Boolean = problems().isEmpty() +} + +/** A reason a [CuratedSchema] cannot be published or believed. See [CuratedSchema.problems]. */ +enum class CuratedSchemaProblem { + IdentifierMissing, + IdentifierTooLong, + TitleMissing, + TitleTooLong, + NameMissing, + NameTooLong, + DescriptionMissing, + DescriptionTooLong, + VisibilityMissing, + PictureNotHttps, + DomainInvalid, + NoFields, + RelayInvalid, +} + +/** + * Who may suggest to a list, and who it is meant for. + * + * Suggesting only. Curation is never delegated: a public list still has exactly + * one curator, and the `p` tags on a closed list name extra *suggesters*. + * + * Relays are open, so [Private] is a convention clients follow rather than an + * encryption anybody enforces. Nothing secret belongs in a private list. + */ +enum class CuratedVisibility(val value: String) { + /** Anyone may suggest; anyone may read. */ + Public("public"), + + /** The curator and the `p` tags may suggest; anyone may read. */ + Closed("closed"), + + /** The curator and the `p` tags may suggest; clients should show it only to them. */ + Private("private"), + ; + + companion object { + /** Case-insensitive, trimmed, and null for anything but the three words. */ + fun parse(value: String): CuratedVisibility? = + value.trim().lowercase().let { word -> entries.firstOrNull { it.value == word } } + } +} + +/** + * What kind of value a field takes. One validation rule each, in the NIP's + * table; a form picks its input from this. + * + * [isNumeric] decides what `max` means: a maximum *value* for these, a maximum + * *length* for everything else. The double duty is safe because a field is one + * or the other, never both. + */ +enum class CuratedFieldType(val value: String, val isNumeric: Boolean = false) { + /** Single-line string, length-capped. */ + Text("text"), + + /** Multi-line string. */ + LongText("longtext"), + + /** One word, no whitespace -- `imdb:tt2821314`. */ + Token("token"), + + /** An http(s) URL; `https` in the config narrows it. */ + Url("url"), + + /** A URL that must be https, since browsers block insecure images. */ + Image("image"), + + /** One of the config's `options`. */ + Enum("enum"), + + /** An integer year, bounded by `min` and `max`. */ + Year("year", isNumeric = true), + + /** A positive integer number of seconds, bounded by `min` and `max`. */ + Duration("duration", isNumeric = true), + + /** An integer, bounded by `min` and `max`. */ + Number("number", isNumeric = true), + ; + + companion object { + fun parse(value: String): CuratedFieldType? = entries.firstOrNull { it.value == value } + } +} + +/** + * Everything about a field beyond its name, type, optionality, placeholder and + * label -- the JSON object in the seventh position of a `field` tag. + * + * Null throughout means "not said", and a key that is not said is not written, + * so a config with nothing in it is `{}`. [others] carries keys this app does + * not know, so editing a field another client wrote does not silently strip + * what that client meant by it. + */ +data class CuratedFieldConfig( + /** The tag the field writes to. Defaults to the field's name; [CONTENT_TAG] means the body. */ + val tag: String? = null, + /** A marker written as the tag's third element -- `["r", url, "watch"]`. */ + val marker: String? = null, + /** Maximum characters for text-ish types, maximum value for numeric ones. */ + val max: Long? = null, + /** Minimum value. Numeric types only. */ + val min: Long? = null, + /** The allowed values of an enum. */ + val options: List? = null, + /** Require https on a url. */ + val https: Boolean? = null, + /** The tag may appear more than once. */ + val repeat: Boolean? = null, + /** A regular expression the trimmed value must match end to end. */ + val pattern: String? = null, + /** The client fills the value in and must not prompt for it. */ + val derived: Boolean? = null, + /** Helper text under the input. */ + val hint: String? = null, + /** Keys this app does not know, kept as they came. */ + val others: Map = emptyMap(), +) { + /** The blob as it goes into the tag. Known keys first, in the NIP's order, then [others]. */ + fun toJson(): String = buildJsonObject { + tag?.let { put(KEY_TAG, it) } + marker?.let { put(KEY_MARKER, it) } + max?.let { put(KEY_MAX, it) } + min?.let { put(KEY_MIN, it) } + options?.let { put(KEY_OPTIONS, JsonArray(it.map(::JsonPrimitive))) } + https?.let { put(KEY_HTTPS, it) } + repeat?.let { put(KEY_REPEAT, it) } + pattern?.let { put(KEY_PATTERN, it) } + derived?.let { put(KEY_DERIVED, it) } + hint?.let { put(KEY_HINT, it) } + others.forEach { (key, value) -> put(key, value) } + }.toString() + + companion object { + /** The `tag` value meaning the event's content rather than a tag. */ + const val CONTENT_TAG = "content" + + private const val KEY_TAG = "tag" + private const val KEY_MARKER = "marker" + private const val KEY_MAX = "max" + private const val KEY_MIN = "min" + private const val KEY_OPTIONS = "options" + private const val KEY_HTTPS = "https" + private const val KEY_REPEAT = "repeat" + private const val KEY_PATTERN = "pattern" + private const val KEY_DERIVED = "derived" + private const val KEY_HINT = "hint" + + private val KNOWN_KEYS = setOf( + KEY_TAG, KEY_MARKER, KEY_MAX, KEY_MIN, KEY_OPTIONS, + KEY_HTTPS, KEY_REPEAT, KEY_PATTERN, KEY_DERIVED, KEY_HINT, + ) + + /** + * The blob read back, leniently. + * + * A malformed blob costs the field its constraints and not its life: the + * NIP says a bad config must not invalidate the field, so anything that + * is not a JSON object reads as no config at all, and a known key of the + * wrong type is dropped rather than kept as something it is not. + */ + fun fromJson(json: String?): CuratedFieldConfig { + if (json.isNullOrBlank()) return CuratedFieldConfig() + + val obj = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() as? JsonObject + ?: return CuratedFieldConfig() + + fun string(key: String): String? = + (obj[key] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + + fun long(key: String): Long? = + (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull + + fun boolean(key: String): Boolean? = + (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull + + return CuratedFieldConfig( + tag = string(KEY_TAG), + marker = string(KEY_MARKER), + max = long(KEY_MAX), + min = long(KEY_MIN), + options = (obj[KEY_OPTIONS] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.contentOrNull }, + https = boolean(KEY_HTTPS), + repeat = boolean(KEY_REPEAT), + pattern = string(KEY_PATTERN), + derived = boolean(KEY_DERIVED), + hint = string(KEY_HINT), + others = obj.filterKeys { it !in KNOWN_KEYS }, + ) + } + } +} + +/** + * One field an entry may -- or must -- carry. + * + * ``` + * ["field", name, type, "required" | "optional", placeholder, label, config] + * ``` + * + * The four things a form needs are positional and everything else is the JSON + * blob in the last position, which is [CuratedFieldConfig]. + */ +data class CuratedField( + /** Stable name; the key a form collects the value under. */ + val name: String, + val type: CuratedFieldType, + val isRequired: Boolean, + /** An example value shown in an empty input. May be empty. */ + val placeholder: String = "", + /** The human label for the input. Empty means [name]. */ + val label: String = "", + val config: CuratedFieldConfig = CuratedFieldConfig(), +) { + /** The tag this field writes to; [CuratedFieldConfig.CONTENT_TAG] means the body. */ + fun tag(): String = config.tag ?: name + + fun labelOrName(): String = label.ifBlank { name } + + fun toTagArray(): Array = arrayOf( + TAG_NAME, + name, + type.value, + if (isRequired) REQUIRED else OPTIONAL, + placeholder, + label, + config.toJson(), + ) + + companion object { + const val TAG_NAME = "field" + + private const val REQUIRED = "required" + private const val OPTIONAL = "optional" + + /** + * The tag read back, or null for one that is not a field. + * + * Null for a blank name or an unknown type, which are the two things a + * form cannot do anything with. Everything after them is optional: a + * missing placeholder is empty, a missing label falls back to the name, + * and a missing or malformed config is no config -- see + * [CuratedFieldConfig.fromJson]. + */ + fun parse(tag: Array): CuratedField? { + if (tag.size < 3 || tag[0] != TAG_NAME) return null + + val name = tag[1].trim() + if (name.isEmpty()) return null + + val type = CuratedFieldType.parse(tag[2]) ?: return null + + return CuratedField( + name = name, + type = type, + isRequired = tag.getOrNull(3) == REQUIRED, + placeholder = tag.getOrNull(4) ?: "", + label = tag.getOrNull(5) ?: "", + config = CuratedFieldConfig.fromJson(tag.getOrNull(6)), + ) + } + + /** The `title` field a schema gets when it left its own out. */ + val TITLE = CuratedField( + name = "title", + type = CuratedFieldType.Text, + isRequired = true, + label = "Title", + config = CuratedFieldConfig(max = 200), + ) + + /** + * The `d` field a schema gets when it left its own out. Derived, so a + * form never asks for it: the client works an identifier out of the + * entry itself. + */ + val IDENTIFIER = CuratedField( + name = "identifier", + type = CuratedFieldType.Token, + isRequired = true, + label = "Identifier", + config = CuratedFieldConfig(tag = "d", max = 80, derived = true), + ) + + /** + * The two fields every list has, keyed by the tag each writes to and in + * the order [CuratedSchema.normalized] puts them back -- title first, so + * that `d` ends up in front of it. + */ + val MANDATORY: List> = listOf( + "title" to TITLE, + "d" to IDENTIFIER, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt new file mode 100644 index 00000000..37570a6e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt @@ -0,0 +1,206 @@ +package press.mantra.compose.nostr.curated + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.time.Clock + +/** + * Kind 31889: a curated schema event, and the two kinds it governs. + * + * ``` + * 31889 curated schema the definition of a list, by its curator + * 31888 curated suggestion an entry anyone proposes, in reply to the schema + * 31890 curated canonical a suggestion the curator republished as the list's own + * ``` + * + * All three are addressable, so `kind:pubkey:d` names one entry and republishing + * with the same `d` replaces it. That is what makes a schema revisable: editing + * one signs a new event under the same coordinate, and every suggestion that + * replied to the coordinate -- rather than to an event id -- stays attached. + * + * This object is the wire shape: tags in, tags out. [CuratedSchema] is what the + * tags mean, and `GroupCuratedSchema` is a group's own signed one. + */ +object CuratedSchemaEvent { + const val KIND: Kind = 31889 + + /** The kind a schema's suggestions are published under -- its `k` tag. */ + const val SUGGESTION_KIND: Kind = 31888 + + /** The kind the curator republishes an accepted suggestion as. */ + const val CANONICAL_KIND: Kind = 31890 + + /** Length caps on the identity tags, the reference module's. */ + const val MAX_IDENTIFIER = 100 + const val MAX_TITLE = 200 + const val MAX_NAME = 100 + const val MAX_DESCRIPTION = 500 + const val MAX_URL = 500 + const val MAX_DOMAIN = 253 + + private const val TAG_IDENTIFIER = "d" + private const val TAG_TITLE = "title" + private const val TAG_NAME = "name" + private const val TAG_DESCRIPTION = "description" + private const val TAG_KIND = "k" + private const val TAG_VISIBILITY = "visibility" + private const val TAG_PICTURE = "picture" + private const val TAG_DOMAIN = "domain" + private const val TAG_REQUIRE_ANY = "require-any" + private const val TAG_SUGGESTER = "p" + private const val TAG_RELAY = "relay" + + /** The addressable coordinate: what a suggestion's `a` root names. */ + fun coordinate(pubkey: HexKey, identifier: String): String = "$KIND:$pubkey:$identifier" + + /** + * The schema an event declares, or null when the event is not a usable one. + * + * Defensive in the way a relay demands. The wrong kind is null. Any of + * [CuratedSchema.problems] is null -- a schema that would be rejected on + * publish is rejected on read, and for the same reasons. A `field` tag that + * will not parse is dropped and the rest are kept, which is the one place + * the NIP asks for tolerance; a `require-any` naming fewer than two fields + * says nothing and goes the same way. + * + * What comes back is [CuratedSchema.normalized]: the two mandatory fields + * forced in, whatever the event said. + */ + fun parse(event: Event): CuratedSchema? { + if (event.kind != KIND) return null + + return read(event.tags, event.content).takeIf { it.isUsable() }?.normalized() + } + + /** + * The tags as a schema, whether or not they make a usable one. + * + * Split from [parse] so that a reader wanting the reasons -- the editor, + * a test -- can ask [CuratedSchema.problems] of the result. The identity + * values are trimmed, and the description falls back to the content the way + * generic clients read it: the tag is authoritative and the content is the + * mirror. + */ + fun read(tags: Array>, content: String): CuratedSchema { + fun first(name: String): String? = + tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1) + + fun all(name: String): List = + tags.filter { it.size > 1 && it[0] == name }.map { it[1] } + + return CuratedSchema( + identifier = first(TAG_IDENTIFIER)?.trim() ?: "", + title = first(TAG_TITLE)?.trim() ?: "", + name = first(TAG_NAME)?.trim() ?: "", + description = (first(TAG_DESCRIPTION)?.trim()?.ifEmpty { null } ?: content).trim(), + // Null for an absent or unrecognised word, and null stays null: see + // the field's own note on why it is never defaulted. + visibility = first(TAG_VISIBILITY)?.let(CuratedVisibility::parse), + picture = first(TAG_PICTURE)?.trim()?.ifEmpty { null }, + domain = first(TAG_DOMAIN)?.trim()?.ifEmpty { null }?.let(::normalizeDomain), + suggestionKind = first(TAG_KIND)?.trim()?.toIntOrNull() ?: SUGGESTION_KIND, + fields = tags.mapNotNull(CuratedField::parse), + requireAny = tags + .filter { it.isNotEmpty() && it[0] == TAG_REQUIRE_ANY } + .map { group -> group.drop(1).map { it.trim() }.filter { it.isNotEmpty() } } + .filter { it.size > 1 }, + // Only what is a pubkey. A `p` tag holding anything else names nobody, + // and keeping it would have the editor render it as an npub it is not. + suggesters = all(TAG_SUGGESTER).map { it.trim().lowercase() }.filter(::isPubkey).distinct(), + relays = all(TAG_RELAY).map { it.trim() }.distinct(), + ) + } + + /** + * The unsigned event that publishes [schema], for the group to sign. + * + * Built from the schema alone rather than from the group's last event, the + * way a relay list is and for the same reason: a schema is one document + * whose whole point in being re-signed is to replace the last one, and + * carrying an old tag forward would make removing a field impossible. + * + * The content mirrors the description so that a client reading content + * rather than tags still shows something; the tag is the authoritative one. + */ + fun template( + schema: CuratedSchema, + createdAt: Long = Clock.System.now().epochSeconds, + ): EventTemplate { + val normalized = schema.normalized() + + val tags = buildList { + add(arrayOf(TAG_IDENTIFIER, normalized.identifier)) + add(arrayOf(TAG_TITLE, normalized.title)) + add(arrayOf(TAG_NAME, normalized.name)) + add(arrayOf(TAG_DESCRIPTION, normalized.description)) + add(arrayOf(TAG_KIND, normalized.suggestionKind.toString())) + // Absent rather than invented when the schema has none. Every reader, + // this one included, then refuses the event -- which is the right + // outcome for a schema that does not say who may suggest to it, and + // one the editor never produces. + normalized.visibility?.let { add(arrayOf(TAG_VISIBILITY, it.value)) } + + normalized.picture?.let { add(arrayOf(TAG_PICTURE, it)) } + normalized.domain?.let { add(arrayOf(TAG_DOMAIN, normalizeDomain(it))) } + + normalized.fields.forEach { add(it.toTagArray()) } + normalized.requireAny.forEach { group -> add(arrayOf(TAG_REQUIRE_ANY, *group.toTypedArray())) } + normalized.suggesters.forEach { add(arrayOf(TAG_SUGGESTER, it)) } + normalized.relays.forEach { add(arrayOf(TAG_RELAY, it)) } + } + + return EventTemplate( + createdAt = createdAt, + kind = KIND, + tags = tags.toTypedArray(), + content = normalized.description, + ) + } + + /** + * A hostname and nothing else: at least two labels, each of letters, digits + * and inner hyphens, no scheme, port or path, 253 characters at most. What + * [normalizeDomain] leaves of a pasted URL is checked against this, so + * `https://Bitcoin.MOV/` is accepted and stored as `bitcoin.mov`. + */ + private val DOMAIN = Regex( + "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" + ) + + /** Trim the things people paste around a bare hostname. */ + fun normalizeDomain(value: String): String = value + .trim() + .lowercase() + .replace(Regex("^[a-z][a-z0-9+.-]*://"), "") + .removePrefix("@") + .replace(Regex("/.*$"), "") + .replace(Regex(":\\d+$"), "") + .removeSuffix(".") + + /** 64 lowercase hex characters: an x-only pubkey as nostr writes one. */ + fun isPubkey(value: String): Boolean = + value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' } + + fun isDomain(value: String): Boolean = + normalizeDomain(value).let { it.length <= MAX_DOMAIN && DOMAIN.matches(it) } + + /** + * An https URL with a host: the only kind of picture a schema may carry, + * because it gets rendered and browsers block the rest on a secure page. + */ + fun isHttpsUrl(value: String): Boolean = HTTPS_URL.matches(value.trim()) + + private val HTTPS_URL = Regex("^https://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE) + + /** + * A `ws://` or `wss://` URL with a host, which is what the NIP accepts in a + * `relay` tag. The editor is stricter about what it will let a group sign -- + * see `GroupRelaySet.relayUrlOrNull` -- but a schema read off a relay is held + * to the NIP's rule and not to this app's. + */ + fun isRelayUrl(value: String): Boolean = RELAY_URL.matches(value.trim()) + + private val RELAY_URL = Regex("^wss?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 9b44ee7c..7ac07fa4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.database.model.Participant @@ -113,6 +114,17 @@ interface ChatRepository { */ suspend fun groupPosts(chatRoomId: String): List + /** + * The curated schemas this group signed -- the lists it curates -- one per + * identifier, most recently signed first. + * + * Kind 31889 is addressable, so an edit is a newer event under the same + * identifier and the newest wins per list, the way the profile does; two + * identifiers are two lists and both come back. Empty in a group that + * curates nothing, which is every group until somebody proposes one. + */ + suspend fun groupCuratedSchemas(chatRoomId: String): List + /** * Whether this device holds a share of the group's key, and so could take * part in signing for it. @@ -301,6 +313,10 @@ interface ChatRepository { override suspend fun groupPosts(chatRoomId: String): List = emptyList() + override suspend fun groupCuratedSchemas( + chatRoomId: String + ): List = emptyList() + override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun refuseSubgroup( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 7b11f466..523fca38 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -2,6 +2,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent @@ -155,6 +156,22 @@ object ProposedEvent { ).joinToString(" · ") ) + // A list the group would curate. A member signing this is agreeing to + // take suggestions from whoever the visibility admits and to be the one + // key that accepts them, so the summary is the list's name, who may + // suggest, and how much an entry asks for -- the content is only the + // description, which the detail screen shows whole. + CuratedSchemaEvent.KIND -> Summary( + label = "Curated schema", + detail = CuratedSchemaEvent.read(event.tags, event.content).let { schema -> + listOfNotNull( + schema.displayName().ifBlank { null }, + schema.visibility?.value, + schema.fields.size.let { "$it ${if (it == 1) "field" else "fields"}" } + ).joinToString(" · ") + } + ) + else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 96894ba6..33dabc79 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Ballot +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw @@ -74,6 +77,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -100,6 +104,10 @@ import mantra.composeapp.generated.resources.relays import mantra.composeapp.generated.resources.posts import mantra.composeapp.generated.resources.add_post import mantra.composeapp.generated.resources.a_reply +import mantra.composeapp.generated.resources.curated_schemas +import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet +import mantra.composeapp.generated.resources.add_schema +import mantra.composeapp.generated.resources.fields_colon import mantra.composeapp.generated.resources.posted_on import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet import mantra.composeapp.generated.resources.edit_relays @@ -124,6 +132,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList @@ -521,6 +530,90 @@ fun ChatRoomDetailScreen( } } + // Under the posts, because a schema is the other + // thing a group publishes for strangers to answer: + // a post is the group speaking, a schema is the + // group asking -- for entries to a list it will + // then curate, by quorum, under the same key. It + // closes the identity block because everything in + // that block is signed by that key and read by + // people who were never in the room. + item { + Text( + text = stringResource(Res.string.curated_schemas), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.groupCuratedSchemas.isEmpty()) { + item { + Text( + stringResource( + Res.string.this_group_has_not_published_a_curated_schema_yet + ) + ) + } + } else { + items( + items = chatRoomDetailUIState.groupCuratedSchemas, + key = { curated -> curated.signedEvent.id } + ) { curated -> + GroupCuratedSchemaCard( + curatedSchema = curated, + // Into the editor for a member who + // could propose a revision, and a plain + // card for one who could not: the same + // gate as every button in this block, + // applied to the card because each + // schema is edited on its own. + onEdit = if (chatRoomDetailUIState.canEditNostrProfile) { + { + onNavigateToRoute.invoke( + EditGroupCuratedSchemaRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = curated.identifier + ) + ) + } + } else { + null + } + ) + } + } + + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + EditGroupCuratedSchemaRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = null + ) + ) + } + ) { + Icon( + Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.add_schema)) + } + } + } + item { HorizontalDivider() } @@ -1469,6 +1562,92 @@ private fun GroupPostCard( } } +/** + * One list the group curates, as the schema it signed for it. + * + * Named by the list -- the domain where it claims one, else the name -- because + * that is what a suggester sees the list called; the title under it is the label + * the event carries. The fields are listed by label rather than counted, since + * what a reader wants to know about a list is what an entry in it asks for, and + * "13 fields" answers a different question. + * + * The visibility is on the last line beside the date, because who may suggest is + * the one fact on the card a member might act on -- a closed list is one they + * would have to be named on. + * + * [onEdit] null draws a card that cannot be opened. It is the section's gate + * applied per card: a schema is edited on its own, so the way in has to be on it. + */ +@Composable +private fun GroupCuratedSchemaCard( + curatedSchema: GroupCuratedSchema, + onEdit: (() -> Unit)? +) { + val content: @Composable () -> Unit = { + ListItem( + leadingContent = { + Icon(Icons.Default.Ballot, contentDescription = Decorative) + }, + trailingContent = onEdit?.let { + { Icon(Icons.Default.Edit, contentDescription = "Edit schema") } + }, + headlineContent = { + Text( + text = curatedSchema.schema.displayName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = curatedSchema.schema.title, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = curatedSchema.schema.description, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource( + Res.string.fields_colon, + curatedSchema.schema.fields.joinToString { it.labelOrName() } + ), + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = listOfNotNull( + curatedSchema.schema.visibility?.let { stringResource(it.label()) }, + stringResource( + Res.string.signed_by_the_group_on, + curatedSchema.signedAt.toFormattedTimeAndDateString() + ) + ).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } + + if (onEdit != null) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onEdit) { content() } + } else { + Card(modifier = Modifier.fillMaxWidth()) { content() } + } +} + /** * One subgroup, as the parent's record and this device's rooms together have it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt new file mode 100644 index 00000000..b3cba3f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt @@ -0,0 +1,1521 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.Rule +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Ballot +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Sell +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.Title +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSchemaProblem +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel +import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddRelayFailure +import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddSuggesterFailure +import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldEdit +import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldProblem +import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_description_is_required +import mantra.composeapp.generated.resources.a_domain_replaces_the_name_wherever_the_list_is_shown +import mantra.composeapp.generated.resources.a_field_name_is_one_word +import mantra.composeapp.generated.resources.a_field_named_that_already_exists +import mantra.composeapp.generated.resources.a_field_needs_a_name +import mantra.composeapp.generated.resources.a_name_is_required +import mantra.composeapp.generated.resources.a_regular_expression_the_whole_value_must_match +import mantra.composeapp.generated.resources.a_schema_needs_at_least_one_field +import mantra.composeapp.generated.resources.a_title_is_required +import mantra.composeapp.generated.resources.add_field +import mantra.composeapp.generated.resources.add_rule +import mantra.composeapp.generated.resources.add_schema +import mantra.composeapp.generated.resources.an_entry_must_have_this_field +import mantra.composeapp.generated.resources.an_identifier_is_required +import mantra.composeapp.generated.resources.at_least_one_of +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_schema +import mantra.composeapp.generated.resources.defaults_to_the_field_name_content_means_the_body +import mantra.composeapp.generated.resources.derived +import mantra.composeapp.generated.resources.description +import mantra.composeapp.generated.resources.domain +import mantra.composeapp.generated.resources.done +import mantra.composeapp.generated.resources.edit_field +import mantra.composeapp.generated.resources.edit_schema +import mantra.composeapp.generated.resources.eg_1900 +import mantra.composeapp.generated.resources.eg_200 +import mantra.composeapp.generated.resources.eg_2100 +import mantra.composeapp.generated.resources.eg_a_za_z_2_8 +import mantra.composeapp.generated.resources.eg_bitcoin_mov +import mantra.composeapp.generated.resources.eg_movie_documentary_short +import mantra.composeapp.generated.resources.eg_bitcoin_mov_suggestion +import mantra.composeapp.generated.resources.eg_example_com +import mantra.composeapp.generated.resources.eg_fields_for_a_bitcoin_mov_entry +import mantra.composeapp.generated.resources.eg_https_example_com_group_png +import mantra.composeapp.generated.resources.eg_https_youtube_com_watch +import mantra.composeapp.generated.resources.eg_watch +import mantra.composeapp.generated.resources.eg_watch_reference_url +import mantra.composeapp.generated.resources.eg_watchurl +import mantra.composeapp.generated.resources.eg_wss_relay_example_com +import mantra.composeapp.generated.resources.field_name +import mantra.composeapp.generated.resources.field_type_duration +import mantra.composeapp.generated.resources.field_type_enum +import mantra.composeapp.generated.resources.field_type_image +import mantra.composeapp.generated.resources.field_type_longtext +import mantra.composeapp.generated.resources.field_type_number +import mantra.composeapp.generated.resources.field_type_text +import mantra.composeapp.generated.resources.field_type_token +import mantra.composeapp.generated.resources.field_type_url +import mantra.composeapp.generated.resources.field_type_year +import mantra.composeapp.generated.resources.fields +import mantra.composeapp.generated.resources.filled_in_by_the_app_never_asked_for +import mantra.composeapp.generated.resources.helper_text_under_the_input +import mantra.composeapp.generated.resources.hint +import mantra.composeapp.generated.resources.https_only +import mantra.composeapp.generated.resources.identifier +import mantra.composeapp.generated.resources.label +import mantra.composeapp.generated.resources.marker +import mantra.composeapp.generated.resources.maximum_characters +import mantra.composeapp.generated.resources.maximum_value +import mantra.composeapp.generated.resources.may_appear_more_than_once +import mantra.composeapp.generated.resources.minimum_value +import mantra.composeapp.generated.resources.name +import mantra.composeapp.generated.resources.new_field +import mantra.composeapp.generated.resources.no_relays_named_so_a_client_will_pick_its_own +import mantra.composeapp.generated.resources.nothing_has_changed_to_propose +import mantra.composeapp.generated.resources.npub_or_hex_pubkey +import mantra.composeapp.generated.resources.one_of_the_relays_is_not_a_relay_address +import mantra.composeapp.generated.resources.one_per_line_or_separated_by_commas +import mantra.composeapp.generated.resources.optional +import mantra.composeapp.generated.resources.options +import mantra.composeapp.generated.resources.pattern +import mantra.composeapp.generated.resources.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have +import mantra.composeapp.generated.resources.picture_url +import mantra.composeapp.generated.resources.placeholder +import mantra.composeapp.generated.resources.propose_schema +import mantra.composeapp.generated.resources.pubkeys_besides_the_group_that_may_suggest_to_this_list +import mantra.composeapp.generated.resources.relay_url +import mantra.composeapp.generated.resources.relays +import mantra.composeapp.generated.resources.remove_field +import mantra.composeapp.generated.resources.required +import mantra.composeapp.generated.resources.say_who_may_suggest_to_this_list +import mantra.composeapp.generated.resources.suggesters +import mantra.composeapp.generated.resources.that_is_not_a_domain_name +import mantra.composeapp.generated.resources.that_is_not_a_pubkey +import mantra.composeapp.generated.resources.that_is_not_a_relay_address +import mantra.composeapp.generated.resources.that_pubkey_is_already_a_suggester +import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list +import mantra.composeapp.generated.resources.the_description_must_be_at_most_n_characters +import mantra.composeapp.generated.resources.the_group_signs_a_schema_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.the_identifier_must_be_at_most_n_characters +import mantra.composeapp.generated.resources.the_identifier_names_this_list_in_every_reply +import mantra.composeapp.generated.resources.the_name_must_be_at_most_n_characters +import mantra.composeapp.generated.resources.the_picture_must_be_an_https_url +import mantra.composeapp.generated.resources.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart +import mantra.composeapp.generated.resources.the_title_must_be_at_most_n_characters +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_schema +import mantra.composeapp.generated.resources.title +import mantra.composeapp.generated.resources.type +import mantra.composeapp.generated.resources.visibility +import mantra.composeapp.generated.resources.visibility_closed +import mantra.composeapp.generated.resources.visibility_closed_purpose +import mantra.composeapp.generated.resources.visibility_private +import mantra.composeapp.generated.resources.visibility_private_purpose +import mantra.composeapp.generated.resources.visibility_public +import mantra.composeapp.generated.resources.visibility_public_purpose +import mantra.composeapp.generated.resources.what_an_entry_in_this_list_may_contain +import mantra.composeapp.generated.resources.where_suggestions_to_this_list_are_published +import mantra.composeapp.generated.resources.writes_to_tag +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** + * The form for a list the group curates: what an entry may contain, who may + * suggest one, and where suggestions go. + * + * One screen for the whole schema, because the schema is one event and one + * signature. The identity -- identifier, title, name, description, visibility, + * picture, domain -- is text fields like every other editor's. Under it are the + * four things a schema has several of: fields, rules saying at least one of some + * fields must be present, extra suggesters on a closed list, and relays. Each + * is a list with a row per entry and a way to add one, and a field is edited on + * a sheet of its own, since a field has a dozen settings and thirteen of them + * inline would be a screen nobody could find anything on. + * + * **The button proposes rather than saves.** The schema goes out as one event + * for the room's quorum to put its key to, and the screen hands over to the + * signing session it opened. Nothing about the list has changed by the time this + * screen closes -- and until a quorum signs, suggesters have nothing to reply to. + * + * **An edit keeps the identifier.** The identifier is the coordinate every + * suggestion to the list replies to, so changing it would not edit the list but + * start a second one and orphan the first's queue. The field is read-only on an + * edit and says why; a new list is "Add schema" on the group's screen. + */ +@OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) +@Composable +fun EditGroupCuratedSchemaScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String?, + initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState = + EditGroupCuratedSchemaUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val editGroupCuratedSchemaViewModel: EditGroupCuratedSchemaViewModel = viewModel( + factory = EditGroupCuratedSchemaViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in a click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_schema) + val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose) + + ScreenStateTransition(editGroupCuratedSchemaViewModel.editGroupCuratedSchemaUIState) { uiState -> + when (val editGroupCuratedSchemaUIState = uiState) { + is EditGroupCuratedSchemaUIState.Error -> { + // Nothing to retry: the room and the identifier came from a + // navigation argument, and reading them again with the same ones + // fails the same way. + ErrorState(message = editGroupCuratedSchemaUIState.message, onRetry = null) + } + + is EditGroupCuratedSchemaUIState.Loaded -> { + val existing = editGroupCuratedSchemaUIState.existing + val schema = existing?.schema + val canSign = editGroupCuratedSchemaUIState.canSign + + // Seeded from the schema the group already signed, so the form is + // an edit of it. `rememberTextFieldState` saves what is typed, which + // is what survives a rotation with the edits intact. + val identifierFieldState = rememberTextFieldState(schema?.identifier ?: "") + val titleFieldState = rememberTextFieldState(schema?.title ?: "") + val nameFieldState = rememberTextFieldState(schema?.name ?: "") + val descriptionFieldState = rememberTextFieldState(schema?.description ?: "") + val pictureFieldState = rememberTextFieldState(schema?.picture ?: "") + val domainFieldState = rememberTextFieldState(schema?.domain ?: "") + val relayFieldState = rememberTextFieldState() + val suggesterFieldState = rememberTextFieldState() + + // The lists come from the state rather than from whatever loaded + // it, so a screen handed a loaded state -- a preview, a layout + // test -- fills in the same as the app does. Seeding runs once. + LaunchedEffect(editGroupCuratedSchemaUIState) { + editGroupCuratedSchemaViewModel.seedWorkingCopy(editGroupCuratedSchemaUIState) + } + + // A refusal is about what is in the field, so it goes the moment + // the field changes rather than sitting under a value that has + // since been corrected. + LaunchedEffect(relayFieldState) { + snapshotFlow { relayFieldState.text.toString() } + .collect { editGroupCuratedSchemaViewModel.clearAddRelayFailure() } + } + LaunchedEffect(suggesterFieldState) { + snapshotFlow { suggesterFieldState.text.toString() } + .collect { editGroupCuratedSchemaViewModel.clearAddSuggesterFailure() } + } + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + editGroupCuratedSchemaUIState.localChatRoom.RenderChatRoomTitleText() + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + editGroupCuratedSchemaViewModel.proposeSchema( + localChatRoom = + editGroupCuratedSchemaUIState.localChatRoom, + existing = existing, + identifier = identifierFieldState.text.toString(), + title = titleFieldState.text.toString(), + name = nameFieldState.text.toString(), + description = descriptionFieldState.text.toString(), + picture = pictureFieldState.text.toString(), + domain = domainFieldState.text.toString(), + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. The schema has not + // changed yet -- it changes when a + // quorum signs -- so landing on a + // group still showing the old one + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // The reasons are drawn on the form + // itself, where the fields they are + // about are; a snackbar would name a + // problem and scroll away from it. + onInvalid = {}, + // Both stay on the form with the edits + // still in it. One is a question and + // one is a failure, and neither is a + // reason to throw away the typing. + onNothingToPropose = { notify(nothingChanged) }, + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon(Icons.Default.Draw, contentDescription = "Propose schema") + Text(stringResource(Res.string.propose_schema)) + } + } + ) + } + ) { innerPadding -> + // One lazy list for the whole form rather than a scrolling + // column, because the form has four lists in it and a lazy + // list cannot sit inside a scrolling column. The text fields + // are declared above so that scrolling one off the screen does + // not lose what was typed into it. + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + contentPadding = PaddingValues( + horizontal = MaterialTheme.spacing.screenMargin, + vertical = MaterialTheme.spacing.itemGap + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + text = stringResource(Res.string.what_an_entry_in_this_list_may_contain), + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center + ) + } + + item { + Text( + text = stringResource( + Res.string.the_group_signs_a_schema_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + if (!canSign) { + item { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_a_schema + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + // Named rather than silent, and all of them at once: a + // button that does nothing for a schema it refuses is + // indistinguishable from a missed tap, and one reason per + // press would be a conversation. + if (editGroupCuratedSchemaViewModel.problems.isNotEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.relatedGap + ) + ) { + editGroupCuratedSchemaViewModel.problems.forEach { problem -> + Text( + text = problem.message(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + + item { + SchemaTextField( + state = identifierFieldState, + icon = Icons.Default.Tag, + iconDescription = "Identifier of the list", + label = stringResource(Res.string.identifier), + placeholder = stringResource(Res.string.eg_bitcoin_mov), + // Locked on an edit: see the screen's own note. + readOnly = existing != null, + supportingText = if (existing != null) { + stringResource(Res.string.the_identifier_names_this_list_in_every_reply) + } else { + null + }, + isError = editGroupCuratedSchemaViewModel.problems.containsAny( + CuratedSchemaProblem.IdentifierMissing, + CuratedSchemaProblem.IdentifierTooLong + ) + ) + } + + item { + SchemaTextField( + state = titleFieldState, + icon = Icons.Default.Title, + iconDescription = "Title of the schema", + label = stringResource(Res.string.title), + placeholder = stringResource(Res.string.eg_bitcoin_mov_suggestion), + isError = editGroupCuratedSchemaViewModel.problems.containsAny( + CuratedSchemaProblem.TitleMissing, + CuratedSchemaProblem.TitleTooLong + ) + ) + } + + item { + SchemaTextField( + state = nameFieldState, + icon = Icons.AutoMirrored.Filled.Label, + iconDescription = "Name of the list", + label = stringResource(Res.string.name), + placeholder = stringResource(Res.string.eg_bitcoin_mov), + isError = editGroupCuratedSchemaViewModel.problems.containsAny( + CuratedSchemaProblem.NameMissing, + CuratedSchemaProblem.NameTooLong + ) + ) + } + + item { + SchemaTextField( + state = descriptionFieldState, + icon = Icons.AutoMirrored.Filled.Notes, + iconDescription = "What the list is for", + label = stringResource(Res.string.description), + placeholder = stringResource( + Res.string.eg_fields_for_a_bitcoin_mov_entry + ), + // The one identity field with prose in it, and the + // only one a single line would truncate while it + // was being typed. + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), + isError = editGroupCuratedSchemaViewModel.problems.containsAny( + CuratedSchemaProblem.DescriptionMissing, + CuratedSchemaProblem.DescriptionTooLong + ) + ) + } + + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.relatedGap + ) + ) { + SectionHeading(stringResource(Res.string.visibility)) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + CuratedVisibility.entries.forEachIndexed { index, option -> + SegmentedButton( + selected = option == editGroupCuratedSchemaViewModel.visibility, + onClick = { + editGroupCuratedSchemaViewModel.selectVisibility(option) + }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = CuratedVisibility.entries.size + ), + label = { Text(stringResource(option.label())) } + ) + } + } + + // What the chosen word actually decides. Three + // words is three questions nobody can be expected + // to hold apart from the words alone. + Text( + text = stringResource( + editGroupCuratedSchemaViewModel.visibility.purpose() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + item { + SchemaTextField( + state = pictureFieldState, + icon = Icons.Default.Image, + iconDescription = "Picture of the list", + label = stringResource(Res.string.picture_url), + placeholder = stringResource(Res.string.eg_https_example_com_group_png), + isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.PictureNotHttps) + ) + } + + item { + SchemaTextField( + state = domainFieldState, + icon = Icons.Default.Language, + iconDescription = "Domain of the list", + label = stringResource(Res.string.domain), + placeholder = stringResource(Res.string.eg_example_com), + supportingText = stringResource( + Res.string.a_domain_replaces_the_name_wherever_the_list_is_shown + ), + isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.DomainInvalid) + ) + } + + item { + SectionHeading(stringResource(Res.string.fields)) + } + + itemsIndexed(editGroupCuratedSchemaViewModel.fields) { index, field -> + SchemaFieldRow( + field = field, + isMandatory = editGroupCuratedSchemaViewModel.isMandatory(field), + onOpen = { editGroupCuratedSchemaViewModel.startEditingField(index) } + ) + } + + item { + TextButton(onClick = { editGroupCuratedSchemaViewModel.startAddingField() }) { + Icon(Icons.Default.Add, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.add_field)) + } + } + + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.relatedGap + ) + ) { + SectionHeading(stringResource(Res.string.at_least_one_of)) + + Text( + text = stringResource( + Res.string.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + itemsIndexed(editGroupCuratedSchemaViewModel.requireAny) { index, group -> + RequireAnyRow( + fieldNames = editGroupCuratedSchemaViewModel.fields.map { it.name }, + selected = group, + onToggle = { fieldName -> + editGroupCuratedSchemaViewModel.toggleRequireAny(index, fieldName) + }, + onRemove = { editGroupCuratedSchemaViewModel.removeRequireAnyGroup(index) } + ) + } + + item { + TextButton( + onClick = { editGroupCuratedSchemaViewModel.addRequireAnyGroup() } + ) { + Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.add_rule)) + } + } + + // Only where the visibility gives them something to be: on + // a public list anyone may suggest, and a list of people + // who may would say the opposite of what the word does. + if (editGroupCuratedSchemaViewModel.visibility != CuratedVisibility.Public) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.relatedGap + ) + ) { + SectionHeading(stringResource(Res.string.suggesters)) + + Text( + text = stringResource( + Res.string.pubkeys_besides_the_group_that_may_suggest_to_this_list + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + items(editGroupCuratedSchemaViewModel.suggesters) { pubkey -> + RemovableRow( + // The npub, because that is the form a member + // pasted it in and the form they would compare + // it against; whole and monospaced, so it can be. + text = pubkey.hexToNpubHrp(), + isMonospaced = true, + removeDescription = "Remove suggester", + onRemove = { editGroupCuratedSchemaViewModel.removeSuggester(pubkey) } + ) + } + + item { + AddRow( + state = suggesterFieldState, + icon = Icons.Default.PersonAdd, + label = stringResource(Res.string.npub_or_hex_pubkey), + placeholder = null, + addDescription = "Add suggester", + failure = when (editGroupCuratedSchemaViewModel.addSuggesterFailure) { + AddSuggesterFailure.NotAPubkey -> + stringResource(Res.string.that_is_not_a_pubkey) + AddSuggesterFailure.AlreadyListed -> + stringResource(Res.string.that_pubkey_is_already_a_suggester) + null -> null + }, + onAdd = { input -> editGroupCuratedSchemaViewModel.addSuggester(input) } + ) + } + } + + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.relatedGap + ) + ) { + SectionHeading(stringResource(Res.string.relays)) + + Text( + text = stringResource( + Res.string.where_suggestions_to_this_list_are_published + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (editGroupCuratedSchemaViewModel.relays.isEmpty()) { + Text( + text = stringResource( + Res.string.no_relays_named_so_a_client_will_pick_its_own + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + items(editGroupCuratedSchemaViewModel.relays) { url -> + RemovableRow( + text = url, + isMonospaced = false, + removeDescription = "Remove relay", + onRemove = { editGroupCuratedSchemaViewModel.removeRelay(url) } + ) + } + + item { + AddRow( + state = relayFieldState, + icon = Icons.Default.Dns, + label = stringResource(Res.string.relay_url), + placeholder = stringResource(Res.string.eg_wss_relay_example_com), + addDescription = "Add relay", + failure = when (editGroupCuratedSchemaViewModel.addRelayFailure) { + AddRelayFailure.NotARelay -> + stringResource(Res.string.that_is_not_a_relay_address) + AddRelayFailure.AlreadyListed -> + stringResource(Res.string.that_relay_is_already_in_this_list) + null -> null + }, + onAdd = { input -> editGroupCuratedSchemaViewModel.addRelay(input) } + ) + } + } + } + + editGroupCuratedSchemaViewModel.editingField?.let { edit -> + FieldEditorSheet( + edit = edit, + isMandatory = editGroupCuratedSchemaViewModel.isMandatory(edit.field), + onCommit = { field -> editGroupCuratedSchemaViewModel.commitField(field) }, + onRemove = { edit.index?.let(editGroupCuratedSchemaViewModel::removeField) }, + onDismiss = { editGroupCuratedSchemaViewModel.dismissFieldEditor() } + ) + } + } + + EditGroupCuratedSchemaUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource( + if (identifier == null) Res.string.add_schema else Res.string.edit_schema + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialEditGroupCuratedSchemaUIState == EditGroupCuratedSchemaUIState.Loading) { + editGroupCuratedSchemaViewModel.initiateEditGroupCuratedSchema() + } + } +} + +/** The word for who may suggest, for the group's screen and the editor alike. */ +internal fun CuratedVisibility.label(): StringResource = when (this) { + CuratedVisibility.Public -> Res.string.visibility_public + CuratedVisibility.Closed -> Res.string.visibility_closed + CuratedVisibility.Private -> Res.string.visibility_private +} + +/** The sentence saying what the word decides. */ +private fun CuratedVisibility.purpose(): StringResource = when (this) { + CuratedVisibility.Public -> Res.string.visibility_public_purpose + CuratedVisibility.Closed -> Res.string.visibility_closed_purpose + CuratedVisibility.Private -> Res.string.visibility_private_purpose +} + +/** + * The name of a field type, for the chips and the rows. + * + * A `when` rather than a field on each entry so that the enum stays what it is + * -- the NIP's nine words and their one rule each -- with no catalogue behind + * it, and so that a tenth type is a compile error here rather than a chip with + * no name. + */ +private fun CuratedFieldType.label(): StringResource = when (this) { + CuratedFieldType.Text -> Res.string.field_type_text + CuratedFieldType.LongText -> Res.string.field_type_longtext + CuratedFieldType.Token -> Res.string.field_type_token + CuratedFieldType.Url -> Res.string.field_type_url + CuratedFieldType.Image -> Res.string.field_type_image + CuratedFieldType.Enum -> Res.string.field_type_enum + CuratedFieldType.Year -> Res.string.field_type_year + CuratedFieldType.Duration -> Res.string.field_type_duration + CuratedFieldType.Number -> Res.string.field_type_number +} + +/** + * Why a schema cannot be proposed, said to the member. + * + * The caps are quoted from `CuratedSchemaEvent` rather than repeated, so the + * sentence and the check cannot disagree about the number. + */ +@Composable +private fun CuratedSchemaProblem.message(): String = when (this) { + CuratedSchemaProblem.IdentifierMissing -> stringResource(Res.string.an_identifier_is_required) + CuratedSchemaProblem.IdentifierTooLong -> stringResource( + Res.string.the_identifier_must_be_at_most_n_characters, + CuratedSchemaEvent.MAX_IDENTIFIER.toString() + ) + CuratedSchemaProblem.TitleMissing -> stringResource(Res.string.a_title_is_required) + CuratedSchemaProblem.TitleTooLong -> stringResource( + Res.string.the_title_must_be_at_most_n_characters, + CuratedSchemaEvent.MAX_TITLE.toString() + ) + CuratedSchemaProblem.NameMissing -> stringResource(Res.string.a_name_is_required) + CuratedSchemaProblem.NameTooLong -> stringResource( + Res.string.the_name_must_be_at_most_n_characters, + CuratedSchemaEvent.MAX_NAME.toString() + ) + CuratedSchemaProblem.DescriptionMissing -> stringResource(Res.string.a_description_is_required) + CuratedSchemaProblem.DescriptionTooLong -> stringResource( + Res.string.the_description_must_be_at_most_n_characters, + CuratedSchemaEvent.MAX_DESCRIPTION.toString() + ) + CuratedSchemaProblem.VisibilityMissing -> stringResource(Res.string.say_who_may_suggest_to_this_list) + CuratedSchemaProblem.PictureNotHttps -> stringResource(Res.string.the_picture_must_be_an_https_url) + CuratedSchemaProblem.DomainInvalid -> stringResource(Res.string.that_is_not_a_domain_name) + CuratedSchemaProblem.NoFields -> stringResource(Res.string.a_schema_needs_at_least_one_field) + CuratedSchemaProblem.RelayInvalid -> stringResource(Res.string.one_of_the_relays_is_not_a_relay_address) +} + +/** Whether any of [problems] is about the field asking, for its error state. */ +private fun List.containsAny(vararg problems: CuratedSchemaProblem): Boolean = + problems.any { it in this } + +/** A heading over one part of the form, at the leading edge like the rows under it. */ +@Composable +private fun SectionHeading(text: String) { + Text( + modifier = Modifier.fillMaxWidth(), + text = text, + style = MaterialTheme.typography.titleSmall + ) +} + +/** + * One text field of the form, in the shape the app's other forms use. + * + * The borderless outlined field over the bottom bar's tint is four lines of + * colours apiece, and this screen has more of them than any other -- so the + * function, and the three things this one adds over `NostrProfileField`: a + * read-only state for the locked identifier, an error state for a field a + * problem is about, and a line of supporting text for a field that needs one. + */ +@Composable +private fun SchemaTextField( + state: TextFieldState, + icon: ImageVector, + iconDescription: String, + label: String, + placeholder: String, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine, + readOnly: Boolean = false, + isError: Boolean = false, + supportingText: String? = null +) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), + state = state, + lineLimits = lineLimits, + readOnly = readOnly, + isError = isError, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(icon, contentDescription = iconDescription) + }, + label = { + Text(text = label) + }, + placeholder = { + Text(text = placeholder) + }, + supportingText = supportingText?.let { { Text(text = it) } } + ) +} + +/** + * One field of the schema, as a row that opens the sheet. + * + * The label is what a suggester will see over the input, so it is the headline; + * the name, type and requirement are what the schema says about it. A + * mandatory field is marked as required whatever its own flag says, since that + * is what will be signed. + */ +@Composable +private fun SchemaFieldRow( + field: CuratedField, + isMandatory: Boolean, + onOpen: () -> Unit +) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) { + ListItem( + leadingContent = { + Icon(Icons.Default.Ballot, contentDescription = Decorative) + }, + headlineContent = { + Text( + text = field.labelOrName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + text = listOfNotNull( + field.name, + stringResource(field.type.label()), + stringResource( + if (field.isRequired || isMandatory) Res.string.required else Res.string.optional + ), + stringResource(Res.string.derived).takeIf { field.config.derived == true } + ).joinToString(" · "), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } +} + +/** + * One `require-any` rule: a chip per field, selected for the ones in the rule. + * + * Chips over a picker because the whole point of a rule is which fields are in + * it, and a chip row shows that at a glance where a picker would show a count. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RequireAnyRow( + fieldNames: List, + selected: List, + onToggle: (String) -> Unit, + onRemove: () -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.compactPadding), + verticalAlignment = Alignment.CenterVertically + ) { + FlowRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + fieldNames.forEach { fieldName -> + FilterChip( + selected = fieldName in selected, + onClick = { onToggle(fieldName) }, + label = { Text(fieldName) } + ) + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove rule", + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} + +/** One entry of a plain list -- a relay, a suggester -- with the way to drop it. */ +@Composable +private fun RemovableRow( + text: String, + isMonospaced: Boolean, + removeDescription: String, + onRemove: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = text, + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isMonospaced) FontFamily.Monospace else null + ) + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = removeDescription, + tint = MaterialTheme.colorScheme.error + ) + } + } +} + +/** + * A field and a button that adds what is in it to a list, and the reason when + * it would not. + * + * [onAdd] answers whether the row appeared, which is the cue to clear the field + * -- a refused value stays where it can be corrected. + */ +@Composable +private fun AddRow( + state: TextFieldState, + icon: ImageVector, + label: String, + placeholder: String?, + addDescription: String, + failure: String?, + onAdd: (String) -> Boolean +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.weight(1f).background(BottomAppBarDefaults.containerColor), + state = state, + lineLimits = TextFieldLineLimits.SingleLine, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(icon, contentDescription = Decorative) + }, + label = { Text(label) }, + placeholder = placeholder?.let { { Text(it) } }, + isError = failure != null + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + IconButton( + onClick = { + if (onAdd(state.text.toString())) { + state.clearText() + } + } + ) { + Icon(Icons.Default.Add, contentDescription = addDescription) + } + } + + failure?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } +} + +/** + * The sheet a field is edited on. + * + * Split into the sheet and its content the way `GroupKeyStateSheet` is, so the + * content can be rendered and measured on its own -- a bottom sheet is a popup + * with its own window, which a layout test cannot reach into. + */ +@Composable +private fun FieldEditorSheet( + edit: FieldEdit, + isMandatory: Boolean, + onCommit: (CuratedField) -> FieldProblem?, + onRemove: () -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet(onDismiss = onDismiss, skipPartiallyExpanded = true) { + FieldEditorSheetContent( + edit = edit, + isMandatory = isMandatory, + onCommit = onCommit, + onRemove = onRemove + ) + } +} + +/** + * Everything a field can say about itself, and a button that puts it in the + * schema. + * + * The positional parts of the tag first -- name, label, placeholder, type, + * required -- because they are what a form needs; the config keys after, and + * only the ones the chosen type gives a meaning to. A minimum on a text field or + * options on a year would be written into the event and mean nothing, so they + * are not offered and, when the type changes away from them, not kept. + * + * **A mandatory field cannot stop being one.** Its tag is read-only and its + * requirement is on and disabled, because `CuratedSchema.normalized` would only + * put a field writing to `d` or `title` back if this let it be taken away -- so + * offering the change would offer something the event will not carry. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun FieldEditorSheetContent( + edit: FieldEdit, + isMandatory: Boolean, + onCommit: (CuratedField) -> FieldProblem?, + onRemove: () -> Unit +) { + val field = edit.field + + val nameFieldState = rememberTextFieldState(field.name) + val labelFieldState = rememberTextFieldState(field.label) + val placeholderFieldState = rememberTextFieldState(field.placeholder) + val tagFieldState = rememberTextFieldState(field.config.tag ?: "") + val markerFieldState = rememberTextFieldState(field.config.marker ?: "") + val maxFieldState = rememberTextFieldState(field.config.max?.toString() ?: "") + val minFieldState = rememberTextFieldState(field.config.min?.toString() ?: "") + val optionsFieldState = rememberTextFieldState(field.config.options?.joinToString("\n") ?: "") + val patternFieldState = rememberTextFieldState(field.config.pattern ?: "") + val hintFieldState = rememberTextFieldState(field.config.hint ?: "") + + var type by rememberSaveable { mutableStateOf(field.type) } + var isRequired by rememberSaveable { mutableStateOf(field.isRequired || isMandatory) } + var mayRepeat by rememberSaveable { mutableStateOf(field.config.repeat == true) } + var isHttpsOnly by rememberSaveable { mutableStateOf(field.config.https == true) } + var isDerived by rememberSaveable { mutableStateOf(field.config.derived == true) } + + var problem: FieldProblem? by remember { mutableStateOf(null) } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + Text( + text = stringResource( + if (edit.index == null) Res.string.new_field else Res.string.edit_field + ), + style = MaterialTheme.typography.titleMedium + ) + + SchemaTextField( + state = nameFieldState, + icon = Icons.Default.Tag, + iconDescription = "Name of the field", + label = stringResource(Res.string.field_name), + placeholder = stringResource(Res.string.eg_watchurl), + isError = problem != null + ) + + // Named rather than silent, under the field it is about. + problem?.let { + Text( + text = when (it) { + FieldProblem.NameMissing -> stringResource(Res.string.a_field_needs_a_name) + FieldProblem.NameHasSpaces -> stringResource(Res.string.a_field_name_is_one_word) + FieldProblem.NameTaken -> stringResource(Res.string.a_field_named_that_already_exists) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + SchemaTextField( + state = labelFieldState, + icon = Icons.AutoMirrored.Filled.Label, + iconDescription = "Label over the input", + label = stringResource(Res.string.label), + placeholder = stringResource(Res.string.eg_watch_reference_url) + ) + + SchemaTextField( + state = placeholderFieldState, + icon = Icons.AutoMirrored.Filled.ShortText, + iconDescription = "Example shown in an empty input", + label = stringResource(Res.string.placeholder), + placeholder = stringResource(Res.string.eg_https_youtube_com_watch) + ) + + Text( + text = stringResource(Res.string.type), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + CuratedFieldType.entries.forEach { option -> + FilterChip( + selected = option == type, + onClick = { type = option }, + label = { Text(stringResource(option.label())) } + ) + } + } + + SwitchRow( + label = stringResource(Res.string.an_entry_must_have_this_field), + checked = isRequired, + enabled = !isMandatory, + onCheckedChange = { isRequired = it } + ) + + SchemaTextField( + state = tagFieldState, + icon = Icons.Default.Sell, + iconDescription = "Tag the field writes to", + label = stringResource(Res.string.writes_to_tag), + placeholder = nameFieldState.text.toString().ifBlank { stringResource(Res.string.eg_watchurl) }, + readOnly = isMandatory, + supportingText = stringResource( + Res.string.defaults_to_the_field_name_content_means_the_body + ) + ) + + SchemaTextField( + state = markerFieldState, + icon = Icons.Default.Bookmark, + iconDescription = "Marker on the tag", + label = stringResource(Res.string.marker), + placeholder = stringResource(Res.string.eg_watch), + supportingText = stringResource( + Res.string.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart + ) + ) + + SchemaTextField( + state = maxFieldState, + icon = Icons.Default.Numbers, + iconDescription = "Maximum", + label = stringResource( + if (type.isNumeric) Res.string.maximum_value else Res.string.maximum_characters + ), + placeholder = stringResource(if (type.isNumeric) Res.string.eg_2100 else Res.string.eg_200) + ) + + if (type.isNumeric) { + SchemaTextField( + state = minFieldState, + icon = Icons.Default.Numbers, + iconDescription = "Minimum", + label = stringResource(Res.string.minimum_value), + placeholder = stringResource(Res.string.eg_1900) + ) + } + + if (type == CuratedFieldType.Enum) { + SchemaTextField( + state = optionsFieldState, + icon = Icons.Default.Checklist, + iconDescription = "Allowed values", + label = stringResource(Res.string.options), + placeholder = stringResource(Res.string.eg_movie_documentary_short), + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 6), + supportingText = stringResource(Res.string.one_per_line_or_separated_by_commas) + ) + } + + if (type == CuratedFieldType.Url) { + SwitchRow( + label = stringResource(Res.string.https_only), + checked = isHttpsOnly, + enabled = true, + onCheckedChange = { isHttpsOnly = it } + ) + } + + SwitchRow( + label = stringResource(Res.string.may_appear_more_than_once), + checked = mayRepeat, + enabled = true, + onCheckedChange = { mayRepeat = it } + ) + + SchemaTextField( + state = patternFieldState, + icon = Icons.AutoMirrored.Filled.Rule, + iconDescription = "Pattern the value must match", + label = stringResource(Res.string.pattern), + placeholder = stringResource(Res.string.eg_a_za_z_2_8), + supportingText = stringResource( + Res.string.a_regular_expression_the_whole_value_must_match + ) + ) + + SwitchRow( + label = stringResource(Res.string.filled_in_by_the_app_never_asked_for), + checked = isDerived, + enabled = true, + onCheckedChange = { isDerived = it } + ) + + SchemaTextField( + state = hintFieldState, + icon = Icons.Default.Lightbulb, + iconDescription = "Helper text", + label = stringResource(Res.string.hint), + placeholder = stringResource(Res.string.helper_text_under_the_input) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (!isMandatory && edit.index != null) { + TextButton( + onClick = onRemove, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(Res.string.remove_field)) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + onClick = { + problem = onCommit( + CuratedField( + name = nameFieldState.text.toString().trim(), + type = type, + isRequired = isRequired || isMandatory, + placeholder = placeholderFieldState.text.toString().trim(), + label = labelFieldState.text.toString().trim(), + config = field.config.copy( + // Pinned for a mandatory field, so that renaming it + // cannot move it off the tag that makes it one. + tag = if (isMandatory) { + field.tag() + } else { + tagFieldState.text.toString().trim().ifEmpty { null } + }, + marker = markerFieldState.text.toString().trim().ifEmpty { null }, + max = maxFieldState.text.toString().trim().toLongOrNull(), + min = if (type.isNumeric) { + minFieldState.text.toString().trim().toLongOrNull() + } else { + null + }, + options = if (type == CuratedFieldType.Enum) { + optionsFieldState.text.toString() + .split('\n', ',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .ifEmpty { null } + } else { + null + }, + https = if (type == CuratedFieldType.Url && isHttpsOnly) true else null, + repeat = if (mayRepeat) true else null, + pattern = patternFieldState.text.toString().trim().ifEmpty { null }, + derived = if (isDerived) true else null, + hint = hintFieldState.text.toString().trim().ifEmpty { null }, + ) + ) + ) + } + ) { + Text(stringResource(Res.string.done)) + } + } + } +} + +/** + * A labelled switch, the label taking the width and the switch its own target. + * + * The label is also the switch's description, so a screen reader announces what + * is being turned on rather than "switch, off" beside a sentence it has already + * read past. + */ +@Composable +private fun SwitchRow( + label: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = label, + style = MaterialTheme.typography.bodyMedium + ) + + Switch( + modifier = Modifier.semantics { contentDescription = label }, + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange + ) + } +} + +@ConformancePreviews +@Composable +private fun EditGroupCuratedSchemaScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + EditGroupCuratedSchemaScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + identifier = null, + initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // A new schema, so the editor seeds the two fields every list + // has and the preview shows what a group actually starts from + // rather than an empty form. + defaultRelays = listOf("wss://relay.example.com"), + // A group that can sign, so the form renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index d3be59ff..773b6dad 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -42,6 +42,7 @@ import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen import press.mantra.compose.ui.composable.AddGroupPostScreen +import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen @@ -127,6 +128,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -1034,6 +1036,27 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + EditGroupCuratedSchemaScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + identifier = route.identifier, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the editor so back returns to the group rather + // than to a schema whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt new file mode 100644 index 00000000..a56bf15b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt @@ -0,0 +1,20 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The editor for one of a group's curated schemas. + * + * [identifier] is the schema's `d`, and null opens the editor on a new one. It + * is the identifier rather than the event id because kind 31889 is addressable: + * an edit replaces the event under the same `d`, and the editor's job is to + * propose that replacement, so the coordinate is what names the thing being + * edited and the event is only its current version. + */ +@Serializable +data class EditGroupCuratedSchemaRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String?, + val identifier: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 144507b2..7844c5ce 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -91,6 +91,7 @@ class ChatRoomDetailViewModel( groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), groupRelayLists = chatRepository.groupRelayLists(chatRoomId), groupPosts = chatRepository.groupPosts(chatRoomId), + groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId), canEditNostrProfile = canSign, canAddSubgroup = canSign, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt new file mode 100644 index 00000000..03847a12 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt @@ -0,0 +1,544 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.bech32ToHexOrNull +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSchemaProblem +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * One of the group's curated schemas, edited locally and proposed as one event. + * + * Nothing here is saved. The parts of the schema that are lists -- its fields, + * its rules, its suggesters, its relays -- live on this view model as snapshot + * state so that a bottom sheet opening over the form, or the window turning, + * does not throw an edit away; the identity fields are text and live in the + * composition like every other form's. The button folds both halves into a + * [CuratedSchema], refuses it for any reason a reader would, and otherwise opens + * one signing session over it. The schema changes on every member's device at + * once, when a quorum has signed, or not at all. + * + * ### Edit and replace + * + * Kind 31889 is addressable, so proposing a schema under an identifier the group + * already signed one for *replaces* it -- which is what an edit is, and why the + * identifier is the one thing the editor will not let an edit change. A new + * identifier is a new list, with a queue of its own, and the group's screen + * offers that as adding a schema rather than as editing this one. + * + * The comparison for "nothing changed" is against the schema as the group signed + * it, normalized on both sides, because re-signing an identical schema would put + * a second statement on the record with a newer timestamp: harmless, since the + * kind is replaceable, and still a lie about when the group last decided + * anything about the list. + */ +class EditGroupCuratedSchemaViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + /** The `d` of the schema being edited, or null for a new one. */ + val identifier: String?, + initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var editGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState by mutableStateOf( + initialEditGroupCuratedSchemaUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** Who may suggest. Always set: the editor never proposes a schema without one. */ + var visibility: CuratedVisibility by mutableStateOf(CuratedVisibility.Public) + private set + + /** The working copy of the schema's fields, in the order they will be written. */ + val fields = mutableStateListOf() + + /** + * The working copy of the `require-any` rules, each a set of field names. + * + * A rule may hold fewer than two names while it is being built; one that + * still does when the button is pressed says nothing and is dropped rather + * than refused -- see `CuratedSchema.requireAny`. + */ + val requireAny = mutableStateListOf>() + + /** The working copy of the `p` tags: pubkeys besides the group that may suggest. */ + val suggesters = mutableStateListOf() + + /** The working copy of the `relay` tags. */ + val relays = mutableStateListOf() + + /** The field the sheet is open on, or null while it is closed. */ + var editingField: FieldEdit? by mutableStateOf(null) + private set + + /** + * Why the last proposal did not go out, or nothing. Set by the button and + * left showing until the next press, since the button is the thing that + * will clear it. + */ + var problems: List by mutableStateOf(emptyList()) + private set + + /** Why the last relay was refused, or null. Cleared by the next keystroke. */ + var addRelayFailure: AddRelayFailure? by mutableStateOf(null) + private set + + /** Why the last suggester was refused, or null. Cleared by the next keystroke. */ + var addSuggesterFailure: AddSuggesterFailure? by mutableStateOf(null) + private set + + private var isSeeded = false + + /** + * A field on the sheet. [index] is its place in [fields], or null for one + * being added -- which is also what decides whether committing it appends + * or replaces. + */ + data class FieldEdit(val index: Int?, val field: CuratedField) + + enum class AddRelayFailure { + /** Not a `wss://` URL, or one pointing at this machine. */ + NotARelay, + + /** Already named, which is a no-op rather than a mistake. */ + AlreadyListed, + } + + enum class AddSuggesterFailure { + /** Neither an npub nor 64 hex characters. */ + NotAPubkey, + + AlreadyListed, + } + + /** Why a field cannot be committed as it stands. */ + enum class FieldProblem { + NameMissing, + + /** A field name is the key a form collects a value under, and a key is one word. */ + NameHasSpaces, + + /** Another field already answers to that name. */ + NameTaken, + } + + fun initiateEditGroupCuratedSchema() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + editGroupCuratedSchemaUIState = if (localChatRoom == null) { + EditGroupCuratedSchemaUIState.Error("Couldn't find the chat room") + } else { + val existing = identifier?.let { wanted -> + chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == wanted } + } + + if (identifier != null && existing == null) { + // Opened on a schema this device does not hold. Nothing to + // retry and nothing to edit; offering a blank form under that + // identifier would propose a replacement for a list nobody + // here has read. + EditGroupCuratedSchemaUIState.Error("Couldn't find that schema") + } else { + EditGroupCuratedSchemaUIState.Loaded( + localChatRoom = localChatRoom, + existing = existing, + defaultRelays = defaultRelays(chatRoomId), + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + } + + /** + * Where a new schema says its list lives: the relays the group has said it + * writes to, since that is where its canonical entries would be read from. + * + * The NIP-65 list where the group has agreed one, else that set's defaults + * -- which is this build's own relay, and the same thing a group opening the + * relay editor for the first time is shown. A schema with no relays is one + * whose suggestions could go anywhere, so the seed is worth getting right. + */ + private suspend fun defaultRelays(chatRoomId: String): List { + val general = chatRepository.groupRelayLists(chatRoomId) + .firstOrNull { it.set == GroupRelaySet.General } + ?: return GroupRelaySet.General.defaults().map { it.url.url } + + return (if (general.isAgreed) general.relays else general.set.defaults()) + .filter { it.write } + .map { it.url.url } + } + + /** + * Fills the working copy from the schema the group signed, once. + * + * An existing schema starts at itself. A new one starts at the two fields + * every list has -- the identifier and the title, which `CuratedSchema.normalized` + * would put back anyway, shown up front so that what the member sees is what + * the group will sign -- and at the group's own relays. + * + * Called from the screen rather than from [initiateEditGroupCuratedSchema], + * because the loader is not the only way a loaded state arrives: a preview + * and a layout test both hand one straight in. Runs once, so the effect that + * calls it re-firing does not undo an edit. + */ + fun seedWorkingCopy(state: EditGroupCuratedSchemaUIState.Loaded) { + if (isSeeded) return + isSeeded = true + + val schema = state.existing?.schema + if (schema != null) { + visibility = schema.visibility ?: CuratedVisibility.Public + fields.addAll(schema.fields) + requireAny.addAll(schema.requireAny) + suggesters.addAll(schema.suggesters) + relays.addAll(schema.relays) + } else { + visibility = CuratedVisibility.Public + fields.addAll(listOf(CuratedField.IDENTIFIER, CuratedField.TITLE)) + relays.addAll(state.defaultRelays) + } + } + + fun selectVisibility(visibility: CuratedVisibility) { + this.visibility = visibility + } + + /** + * Whether [field] is one of the two every list must have. + * + * Decided by the tag it writes to, not its name, because that is what the + * NIP's rule is about: a list needs a field writing to `d` and one writing + * to `title`, whatever they are called. Such a field cannot be removed or + * made optional here -- `CuratedSchema.normalized` would only put it back. + */ + fun isMandatory(field: CuratedField): Boolean = + CuratedField.MANDATORY.any { (tag, _) -> field.tag() == tag } + + /** Opens the sheet on a blank field. Text, because most fields are. */ + fun startAddingField() { + editingField = FieldEdit( + index = null, + field = CuratedField(name = "", type = CuratedFieldType.Text, isRequired = false), + ) + } + + fun startEditingField(index: Int) { + fields.getOrNull(index)?.let { editingField = FieldEdit(index = index, field = it) } + } + + fun dismissFieldEditor() { + editingField = null + } + + /** + * Puts [field] into the working copy at the place the sheet was opened on, + * or says why it cannot. + * + * Null means it went in and the sheet is closed. A rename follows through to + * the rules that named the field, since a rule naming a field that no longer + * exists would silently never be satisfied. + */ + fun commitField(field: CuratedField): FieldProblem? { + val edit = editingField ?: return null + + val name = field.name.trim() + if (name.isEmpty()) return FieldProblem.NameMissing + if (name.any { it.isWhitespace() }) return FieldProblem.NameHasSpaces + if (fields.withIndex().any { (index, other) -> index != edit.index && other.name == name }) { + return FieldProblem.NameTaken + } + + val committed = field.copy(name = name) + + if (edit.index == null) { + fields.add(committed) + } else { + val previous = fields[edit.index] + fields[edit.index] = committed + if (previous.name != name) { + requireAny.indices.forEach { group -> + requireAny[group] = requireAny[group].map { if (it == previous.name) name else it } + } + } + } + + editingField = null + return null + } + + /** Drops the field at [index] and takes it out of any rule that named it. */ + fun removeField(index: Int) { + val removed = fields.getOrNull(index) ?: return + if (isMandatory(removed)) return + + fields.removeAt(index) + requireAny.indices.forEach { group -> + requireAny[group] = requireAny[group].filterNot { it == removed.name } + } + editingField = null + } + + fun addRequireAnyGroup() { + requireAny.add(emptyList()) + } + + fun toggleRequireAny(group: Int, fieldName: String) { + val names = requireAny.getOrNull(group) ?: return + requireAny[group] = if (fieldName in names) names - fieldName else names + fieldName + } + + fun removeRequireAnyGroup(group: Int) { + if (group in requireAny.indices) requireAny.removeAt(group) + } + + /** + * Adds [input] to the suggesters, or says why it did not. + * + * True when the row appeared, which is the caller's cue to clear the field. + */ + fun addSuggester(input: String): Boolean { + val pubkey = pubkeyOrNull(input) ?: run { + addSuggesterFailure = AddSuggesterFailure.NotAPubkey + return false + } + + if (pubkey in suggesters) { + addSuggesterFailure = AddSuggesterFailure.AlreadyListed + return false + } + + suggesters.add(pubkey) + addSuggesterFailure = null + return true + } + + fun removeSuggester(pubkey: HexKey) { + suggesters.remove(pubkey) + } + + fun clearAddSuggesterFailure() { + addSuggesterFailure = null + } + + /** + * Adds [url] to the relays, or says why it did not. + * + * Held to the same rule as the group's relay lists -- `wss://`, not this + * machine -- rather than to the NIP's looser one, because this is a relay + * the group is about to sign for strangers to publish to. See + * `GroupRelaySet.relayUrlOrNull`. + */ + fun addRelay(url: String): Boolean { + val relay = GroupRelaySet.relayUrlOrNull(url) ?: run { + addRelayFailure = AddRelayFailure.NotARelay + return false + } + + // Compared normalized, because a relay signed into an existing schema is + // kept as it was written and the normalizer adds a trailing slash: the + // same host with and without one is one relay, not two. + if (relays.any { GroupRelaySet.relayUrlOrNull(it) == relay }) { + addRelayFailure = AddRelayFailure.AlreadyListed + return false + } + + relays.add(relay.url) + addRelayFailure = null + return true + } + + fun removeRelay(url: String) { + relays.remove(url) + } + + fun clearAddRelayFailure() { + addRelayFailure = null + } + + /** + * The schema as the form has it: the text fields trimmed and the lists as + * they stand, with a rule too short to mean anything left out. + */ + fun draft( + identifier: String, + title: String, + name: String, + description: String, + picture: String, + domain: String, + ): CuratedSchema = CuratedSchema( + identifier = identifier.trim(), + title = title.trim(), + name = name.trim(), + description = description.trim(), + visibility = visibility, + picture = picture.trim().ifEmpty { null }, + domain = domain.trim().ifEmpty { null }?.let(CuratedSchemaEvent::normalizeDomain), + fields = fields.toList(), + requireAny = requireAny.filter { it.size > 1 }, + suggesters = suggesters.toList(), + relays = relays.toList(), + ) + + /** + * Opens a session over the schema, or says why not. + * + * Three refusals before anything is proposed and each is the caller's to + * say: a schema a reader would reject, which is [onInvalid] with the + * reasons; an edit that changed nothing, which is [onNothingToPropose]; and + * a proposal that could not be opened, which is [onFailure]. All of them + * stay on the form with the edits still in it. + */ + fun proposeSchema( + localChatRoom: LocalChatRoom, + existing: GroupCuratedSchema?, + identifier: String, + title: String, + name: String, + description: String, + picture: String, + domain: String, + onSuccess: (sessionId: String) -> Unit, + onInvalid: () -> Unit, + onNothingToPropose: () -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. Two sessions over two versions of one + // schema would leave the list's form decided by whichever quorum + // finished last. + if (isActionPending.value) return + + val schema = draft( + // The identifier of an existing schema is the coordinate every + // suggestion to it replies to, so an edit keeps it whatever the + // field says -- and the field is read-only to say so. + identifier = existing?.identifier ?: identifier, + title = title, + name = name, + description = description, + picture = picture, + domain = domain, + ) + + problems = schema.problems() + if (problems.isNotEmpty()) { + onInvalid.invoke() + return + } + + if (existing != null && existing.schema == schema.normalized()) { + onNothingToPropose.invoke() + return + } + + isActionPending.value = true + + val template = GroupCuratedSchema.template(schema) + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = template.kind, + tags = template.tags, + content = template.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a curated schema for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "EditGroupCuratedSchemaViewModel" + + /** + * [input] as a pubkey the schema could name, or null. + * + * An npub -- with or without a `nostr:` in front, the way it is pasted + * from most clients -- or 64 hex characters in either case. Nothing else, + * and in particular not a NIP-05 address, since resolving one is a + * network round trip and this is a field on a form. + */ + fun pubkeyOrNull(input: String): HexKey? { + val trimmed = input.trim().removePrefix("nostr:") + + val hex = if (trimmed.startsWith("npub1", ignoreCase = true)) { + trimmed.bech32ToHexOrNull() + } else { + trimmed.lowercase() + } + + return hex?.takeIf { candidate -> + candidate.length == 64 && candidate.all { it in '0'..'9' || it in 'a'..'f' } + } + } + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String?, + initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState = + EditGroupCuratedSchemaUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + EditGroupCuratedSchemaViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index e18535f4..601d993a 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -7,6 +7,7 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList @@ -84,6 +85,17 @@ sealed interface ChatRoomDetailUIState { * nothing, which is every group until somebody proposes one. */ val groupPosts: List = emptyList(), + /** + * The lists this group curates, as the schemas it signed for them, most + * recently signed first. + * + * Under the posts because a schema is the other thing a group publishes + * for strangers to answer: a post is the group speaking, a schema is the + * group asking -- for entries to a list it will then curate. Empty in a + * group that curates nothing, which is every group until somebody + * proposes a schema. + */ + val groupCuratedSchemas: List = emptyList(), /** * Whether this device could sign a profile for the group. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt new file mode 100644 index 00000000..d217e1b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt @@ -0,0 +1,42 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedSchema + +sealed interface EditGroupCuratedSchemaUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * The schema being edited, as the group last signed it, or null for a new + * one. + * + * Kept beside the working copy rather than replaced by it, because the + * button proposes only when something differs -- and "differs" needs + * both halves. Null also locks nothing: a new schema's identifier is the + * member's to choose, and an existing one's is not. + */ + val existing: GroupCuratedSchema? = null, + /** + * The relays a new schema starts with: where the group has said it + * publishes, since that is where its list would be read. + * + * Only a new schema is seeded from these. An existing one has its own + * relays signed into it, and those are the ones an edit starts from. + */ + val defaultRelays: List = emptyList(), + /** + * Whether this device holds a share of the group's key. + * + * False leaves the form writable and the button inert, for the same + * reason the other editors do: drafting a schema costs nothing, and only + * a share-holder can open the session that would sign it. + */ + val canSign: Boolean = false, + ): EditGroupCuratedSchemaUIState + + data class Error( + val message: String + ): EditGroupCuratedSchemaUIState + + data object Loading: EditGroupCuratedSchemaUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt new file mode 100644 index 00000000..84751493 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt @@ -0,0 +1,317 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What may be shown as a list the group curates. + * + * The reading gate is `GroupPostTest`'s -- the room has to be the author and the + * signature has to be the room's -- and it carries a weight of its own here: a + * schema read wrong would have strangers filling in a form under a group's name + * that the group never agreed to, and the group's key accepting entries against + * it. The third refusal is this reader's alone: a schema the room really signed + * but that no client could act on is not shown as one it curates. + * + * The ordering half is between the profile's and the posts': one per identifier, + * newest winning within it, because the kind is addressable and a group may + * curate several lists. + */ +class GroupCuratedSchemaTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the schemas signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + private val films = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle, with a link to watch each one.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField( + name = "watchUrl", + type = CuratedFieldType.Url, + isRequired = true, + placeholder = "https://…", + label = "Where to watch", + config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true), + ), + ), + relays = listOf("wss://relay.example.com"), + ) + + @Test + fun `a schema the room signed is a list the group curates`() { + val curated = assertNotNull( + GroupCuratedSchema.of(signedEvent = signed(films), chatRoomId = chatRoomId) + ) + + assertEquals("films", curated.identifier) + assertEquals(chatRoomId, curated.publicKey) + assertEquals("31889:$chatRoomId:films", curated.coordinate()) + assertEquals(films, curated.schema) + assertEquals("Films worth translating", curated.schema.displayName()) + } + + @Test + fun `a schema another group signed is not this group's`() { + // A real quorum and a real signature by a group with no standing to + // curate for this one. The row names this room because that is where it + // was filed; the author is what decides whose list it is. + val stranger = GroupSignedEvent.fromEvent( + event = schemaEvent(films, material = strangerMaterial), + chatRoomId = chatRoomId + ) + + assertNull(GroupCuratedSchema.of(signedEvent = stranger, chatRoomId = chatRoomId)) + } + + @Test + fun `a schema the room did not sign is not one`() { + // Authored by the room and signed by somebody else: the shape a member + // declaring a list in the group's name would produce, and the reason the + // reading verifies rather than trusting the author field. + assertNull( + GroupCuratedSchema.of( + signedEvent = signed(films, signer = strangerMaterial), + chatRoomId = chatRoomId + ) + ) + + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupCuratedSchema.of( + signedEvent = signed(films, signature = rubbish), + chatRoomId = chatRoomId + ), + "a schema signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `an event of another kind is not a schema`() { + assertNull( + GroupCuratedSchema.of( + signedEvent = signed(films).copy(kind = CuratedSchemaEvent.SUGGESTION_KIND), + chatRoomId = chatRoomId + ) + ) + } + + @Test + fun `a schema the room signed but no client could act on is refused`() { + // The room's real signature over a kind 31889 with no visibility. It is + // the group's statement and it is still not a list: a reader that showed + // it would be showing a form nobody may be allowed to fill in. + val unusable = signed( + films, + tags = CuratedSchemaEvent.template(films, createdAt = 1_700_000_000).tags + .filterNot { it[0] == "visibility" } + .toTypedArray() + ) + + assertNull(GroupCuratedSchema.of(signedEvent = unusable, chatRoomId = chatRoomId)) + assertEquals(emptyList(), GroupCuratedSchema.newestPerListAmong(listOf(unusable), chatRoomId)) + } + + @Test + fun `the newest per identifier wins, and two identifiers are two lists`() { + val firstFilms = signed(films, createdAt = 1_700_000_000) + val editedFilms = signed(films.copy(description = "Films the group would subtitle."), createdAt = 1_700_000_900) + val books = signed(films.copy(identifier = "books", name = "Books worth translating"), createdAt = 1_700_000_300) + + listOf( + listOf(firstFilms, editedFilms, books), + listOf(books, editedFilms, firstFilms), + listOf(editedFilms, books, firstFilms), + ).forEach { events -> + val lists = GroupCuratedSchema.newestPerListAmong(events, chatRoomId) + + assertEquals( + listOf("films", "books"), + lists.map { it.identifier }, + "the lists came back in the wrong order, or one of them went missing" + ) + assertEquals( + "Films the group would subtitle.", + lists.first().schema.description, + "an edit did not replace the schema it revised" + ) + } + } + + @Test + fun `two versions signed in the same second resolve the same way on every device`() { + val one = signed(films.copy(description = "One."), createdAt = 1_700_000_000) + val other = signed(films.copy(description = "Other."), createdAt = 1_700_000_000) + + val expected = GroupCuratedSchema.newestPerListAmong(listOf(one, other), chatRoomId).single() + + assertEquals( + expected, + GroupCuratedSchema.newestPerListAmong(listOf(other, one), chatRoomId).single(), + "a tie on the timestamp was broken differently by the two orderings" + ) + assertEquals( + if (one.id > other.id) "One." else "Other.", + expected.schema.description, + "the tie should break on the id, which is the only thing both devices share" + ) + } + + @Test + fun `a schema the editor built reads back as the group defined it`() { + val template = GroupCuratedSchema.template(films) + + assertEquals(CuratedSchemaEvent.KIND, template.kind) + assertEquals(films.description, template.content) + + val curated = assertNotNull( + GroupCuratedSchema.of( + signedEvent = signed(films, tags = template.tags, content = template.content), + chatRoomId = chatRoomId + ) + ) + + assertEquals(films, curated.schema) + assertTrue(curated.schema.fields.first { it.name == "watchUrl" }.isRequired) + } + + /** A schema as `FrostSigningManager.complete` files one. */ + private fun signed( + schema: CuratedSchema, + tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, + content: String = schema.description, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = schemaEvent(schema, tags, content, createdAt, material, signer, signature), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun schemaEvent( + schema: CuratedSchema, + tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, + content: String = schema.description, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = CuratedSchemaEvent.KIND, + tags = tags, + content = content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = CuratedSchemaEvent.KIND, + tags = tags, + content = content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt new file mode 100644 index 00000000..0270d58f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt @@ -0,0 +1,450 @@ +package press.mantra.compose.nostr.curated + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Kind 31889 as the NIP writes it, read and written back. + * + * The fixture is the bitcoin.mov schema from the NIP's own example, tag for tag, + * because the point of publishing a schema is that another client can drive its + * form from it -- so the one thing worth pinning is that what that site published + * reads here, and that what this app signs would read there. + * + * The other half is the rejection list. A schema is what suggestions are checked + * against, and a reader that repaired a broken one would be accepting entries + * against a form nobody signed. Every rule the NIP says a client MUST refuse on + * has a case here, and so does the one repair it says a client MUST make. + */ +class CuratedSchemaEventTest { + private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25" + + private val description = "Fields for a bitcoin.mov entry: a Bitcoin movie, documentary, " + + "short, interview or series, with at least one link to watch or look it up." + + /** The NIP's example, as published. */ + private val bitcoinMov: Array> = arrayOf( + arrayOf("d", "bitcoin.mov"), + arrayOf("title", "bitcoin.mov suggestion"), + arrayOf("name", "bitcoin.mov"), + arrayOf("description", description), + arrayOf("k", "31888"), + arrayOf("visibility", "public"), + arrayOf("picture", "https://bitcoin.mov/icon.svg"), + arrayOf("domain", "bitcoin.mov"), + arrayOf("relay", "wss://ephemeral.mantra.press"), + arrayOf("field", "identifier", "token", "required", "the-rise-and-rise-of-bitcoin-2014", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"), + arrayOf("field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"), + arrayOf("field", "year", "year", "optional", "2014", "Year", "{\"min\":1900,\"max\":2100}"), + arrayOf("field", "type", "enum", "required", "documentary", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"), + arrayOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"), + arrayOf("field", "imdbUrl", "url", "optional", "https://imdb.com/title/tt2821314", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"), + arrayOf("field", "image", "image", "optional", "https://…/poster.jpg", "Poster image URL (https)", "{\"max\":500}"), + arrayOf("field", "hashtags", "token", "optional", "bitcoin", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"), + arrayOf("field", "description", "longtext", "optional", "Why is this worth watching?", "Description / review", "{\"tag\":\"content\",\"max\":4000}"), + arrayOf("require-any", "watchUrl", "imdbUrl"), + ) + + @Test + fun `the bitcoin_mov schema reads as published`() { + val schema = assertNotNull(CuratedSchemaEvent.parse(event())) + + assertEquals("bitcoin.mov", schema.identifier) + assertEquals("bitcoin.mov suggestion", schema.title) + assertEquals("bitcoin.mov", schema.name) + assertEquals(description, schema.description) + assertEquals(CuratedVisibility.Public, schema.visibility) + assertEquals("https://bitcoin.mov/icon.svg", schema.picture) + assertEquals("bitcoin.mov", schema.domain) + assertEquals(31888, schema.suggestionKind) + assertEquals(listOf("wss://ephemeral.mantra.press"), schema.relays) + assertEquals(listOf(listOf("watchUrl", "imdbUrl")), schema.requireAny) + assertEquals(emptyList(), schema.suggesters) + + assertEquals( + listOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"), + schema.fields.map { it.name } + ) + + // The positional parts and the config, on the field that uses most of them. + val watchUrl = schema.fields.first { it.name == "watchUrl" } + assertEquals(CuratedFieldType.Url, watchUrl.type) + assertFalse(watchUrl.isRequired) + assertEquals("https://youtube.com/watch?v=…", watchUrl.placeholder) + assertEquals("Watch / reference URL", watchUrl.label) + assertEquals("r", watchUrl.tag()) + assertEquals("watch", watchUrl.config.marker) + assertEquals(500L, watchUrl.config.max) + assertEquals(true, watchUrl.config.repeat) + + val type = schema.fields.first { it.name == "type" } + assertEquals(listOf("movie", "documentary", "short", "interview", "series", "other"), type.config.options) + + val identifier = schema.fields.first { it.name == "identifier" } + assertEquals("d", identifier.tag()) + assertEquals(true, identifier.config.derived) + assertTrue(identifier.isRequired) + + // Derived fields are filled in by the client, so a form does not ask. + assertEquals( + listOf("title", "year", "type", "watchUrl", "imdbUrl", "image", "description"), + schema.formFields().map { it.name } + ) + } + + @Test + fun `the domain stands in for the name wherever the list is shown`() { + val schema = assertNotNull(CuratedSchemaEvent.parse(event())) + assertEquals("bitcoin.mov", schema.displayName()) + + val withoutDomain = assertNotNull( + CuratedSchemaEvent.parse(event(tags = bitcoinMov.without("domain").replacing("name", "Bitcoin on screen"))) + ) + assertEquals("Bitcoin on screen", withoutDomain.displayName()) + } + + @Test + fun `a schema round trips through the template`() { + val read = assertNotNull(CuratedSchemaEvent.parse(event())) + + val template = CuratedSchemaEvent.template(read, createdAt = 1735689600) + assertEquals(CuratedSchemaEvent.KIND, template.kind) + // The content mirrors the description, for clients that read content. + assertEquals(description, template.content) + + val reread = assertNotNull(CuratedSchemaEvent.parse(event(tags = template.tags, content = template.content))) + assertEquals(read, reread) + + // And the tags come out in the NIP's order, identity first. + assertEquals( + listOf("d", "title", "name", "description", "k", "visibility", "picture", "domain"), + template.tags.take(8).map { it[0] } + ) + assertEquals(9, template.tags.count { it[0] == "field" }) + assertEquals(listOf("require-any", "watchUrl", "imdbUrl"), template.tags.first { it[0] == "require-any" }.toList()) + assertEquals(listOf("relay", "wss://ephemeral.mantra.press"), template.tags.last().toList()) + } + + @Test + fun `a field tag is written the way the NIP shows it`() { + val schema = assertNotNull(CuratedSchemaEvent.parse(event())) + val tag = schema.fields.first { it.name == "watchUrl" }.toTagArray() + + assertEquals( + listOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL", + "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"), + tag.toList() + ) + } + + @Test + fun `a config key this app does not know survives a round trip`() { + // Other clients legitimately add their own. Editing a field here must + // not strip what one of them meant by it. + val field = assertNotNull( + CuratedField.parse(arrayOf("field", "lang", "token", "optional", "en", "Language", "{\"max\":60,\"locale\":\"bcp47\"}")) + ) + + assertEquals(60L, field.config.max) + assertEquals("\"bcp47\"", field.config.others["locale"].toString()) + assertEquals("{\"max\":60,\"locale\":\"bcp47\"}", field.config.toJson()) + } + + @Test + fun `a malformed config costs the field its constraints and not its life`() { + listOf("{not json", "[1,2]", "\"a string\"", "42", "").forEach { blob -> + val field = assertNotNull( + CuratedField.parse(arrayOf("field", "year", "year", "optional", "2014", "Year", blob)), + "the field was dropped over the config \"$blob\"" + ) + assertEquals(CuratedFieldConfig(), field.config, "the config \"$blob\" read as something") + } + + // A known key of the wrong type is dropped rather than kept as something + // it is not; the rest of the blob is kept. + val field = assertNotNull( + CuratedField.parse(arrayOf("field", "year", "year", "optional", "", "", "{\"max\":\"lots\",\"min\":1900}")) + ) + assertNull(field.config.max) + assertEquals(1900L, field.config.min) + } + + @Test + fun `a field with an unknown type or no name is dropped and the rest are kept`() { + val schema = assertNotNull( + CuratedSchemaEvent.parse( + event( + tags = bitcoinMov.plus( + listOf( + arrayOf("field", "rating", "stars", "optional", "", "", "{}"), + arrayOf("field", " ", "text", "optional", "", "", "{}"), + arrayOf("field", "short"), + ) + ) + ) + ) + ) + + assertEquals(9, schema.fields.size) + } + + @Test + fun `a field missing its trailing positions still reads`() { + val field = assertNotNull(CuratedField.parse(arrayOf("field", "director", "text"))) + + assertEquals("director", field.name) + assertFalse(field.isRequired) + assertEquals("", field.placeholder) + assertEquals("director", field.labelOrName()) + assertEquals(CuratedFieldConfig(), field.config) + } + + @Test + fun `a schema missing an identity tag is not usable`() { + mapOf( + "d" to CuratedSchemaProblem.IdentifierMissing, + "title" to CuratedSchemaProblem.TitleMissing, + "name" to CuratedSchemaProblem.NameMissing, + ).forEach { (tag, problem) -> + val tags = without(tag) + assertNull(CuratedSchemaEvent.parse(event(tags = tags)), "a schema without \"$tag\" was accepted") + assertEquals(listOf(problem), CuratedSchemaEvent.read(tags, description).problems()) + } + + // The description alone has a fallback: the content, which is where + // generic clients put it. Empty both and it is missing. + assertNull(CuratedSchemaEvent.parse(event(tags = without("description"), content = ""))) + assertEquals( + listOf(CuratedSchemaProblem.DescriptionMissing), + CuratedSchemaEvent.read(without("description"), " ").problems() + ) + } + + @Test + fun `the description falls back to the content and the tag wins when both are there`() { + val fromContent = assertNotNull( + CuratedSchemaEvent.parse(event(tags = without("description"), content = " What the content says. ")) + ) + assertEquals("What the content says.", fromContent.description) + + val fromTag = assertNotNull(CuratedSchemaEvent.parse(event(content = "Something else entirely"))) + assertEquals(description, fromTag.description) + } + + @Test + fun `visibility is never guessed`() { + assertNull(CuratedSchemaEvent.parse(event(tags = without("visibility")))) + assertEquals( + listOf(CuratedSchemaProblem.VisibilityMissing), + CuratedSchemaEvent.read(without("visibility"), description).problems() + ) + + assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", "friends")))) + assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", "")))) + + // Case and whitespace are forgiven; a fourth word is not. + assertEquals(CuratedVisibility.Closed, CuratedSchemaEvent.parse(event(tags = replacing("visibility", " Closed ")))?.visibility) + assertEquals(CuratedVisibility.Private, CuratedSchemaEvent.parse(event(tags = replacing("visibility", "PRIVATE")))?.visibility) + } + + @Test + fun `a schema with no fields is not usable`() { + val tags = bitcoinMov.filterNot { it[0] == "field" }.toTypedArray() + + assertNull(CuratedSchemaEvent.parse(event(tags = tags))) + assertEquals(listOf(CuratedSchemaProblem.NoFields), CuratedSchemaEvent.read(tags, description).problems()) + + // Checked before the mandatory fields are put back, which is the NIP's + // rule: "no field tags" is about what the publisher said. + val onlyUnparseable = tags.plus(arrayOf("field", "rating", "stars", "optional", "", "", "{}")) + assertNull(CuratedSchemaEvent.parse(event(tags = onlyUnparseable))) + } + + @Test + fun `a picture that is not https is not usable`() { + listOf("http://bitcoin.mov/icon.svg", "bitcoin.mov/icon.svg", "javascript:alert(1)", "https://").forEach { picture -> + assertNull( + CuratedSchemaEvent.parse(event(tags = replacing("picture", picture))), + "a schema with the picture \"$picture\" was accepted" + ) + } + + // Absent is fine; it is optional. + assertNull(CuratedSchemaEvent.parse(event(tags = without("picture")))?.picture) + assertTrue(CuratedSchemaEvent.read(without("picture"), description).isUsable()) + } + + @Test + fun `a domain that is not a hostname is not usable, and one that is gets normalized`() { + listOf("bitcoin", "localhost", "bit coin.mov", "-bitcoin.mov", "bitcoin_mov.example").forEach { domain -> + assertNull( + CuratedSchemaEvent.parse(event(tags = replacing("domain", domain))), + "a schema with the domain \"$domain\" was accepted" + ) + } + + // Lenient on input, strict on what is kept: the things people paste + // around a hostname come off, and what is left is what is checked. + assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "https://Bitcoin.MOV/")))?.domain) + assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "bitcoin.mov:8080/x y")))?.domain) + assertEquals("bitcoin.mov", CuratedSchemaEvent.normalizeDomain("@Bitcoin.mov:443/path.")) + assertTrue(CuratedSchemaEvent.isDomain("sub.example.co.za")) + assertFalse(CuratedSchemaEvent.isDomain("example")) + } + + @Test + fun `a relay that is not a relay is not usable`() { + listOf("https://ephemeral.mantra.press", "ephemeral.mantra.press", "wss://").forEach { relay -> + assertNull( + CuratedSchemaEvent.parse(event(tags = replacing("relay", relay))), + "a schema naming the relay \"$relay\" was accepted" + ) + } + + // The NIP allows ws://, whatever this app will let a group sign. + assertEquals(listOf("ws://localhost:7777"), CuratedSchemaEvent.parse(event(tags = replacing("relay", "ws://localhost:7777")))?.relays) + + // Duplicates collapse; none at all is allowed. + assertEquals( + listOf("wss://ephemeral.mantra.press"), + CuratedSchemaEvent.parse(event(tags = bitcoinMov.plus(arrayOf("relay", "wss://ephemeral.mantra.press"))))?.relays + ) + assertEquals(emptyList(), CuratedSchemaEvent.parse(event(tags = without("relay")))?.relays) + } + + @Test + fun `an over-long identity tag is not usable`() { + assertNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(101))))) + assertNull(CuratedSchemaEvent.parse(event(tags = replacing("d", "d".repeat(101))))) + assertNull(CuratedSchemaEvent.parse(event(tags = replacing("title", "t".repeat(201))))) + assertNull(CuratedSchemaEvent.parse(event(tags = replacing("description", "x".repeat(501))))) + + assertNotNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(100))))) + } + + @Test + fun `the wrong kind is not a schema whatever its tags say`() { + assertNull(CuratedSchemaEvent.parse(event(kind = 31888))) + assertNull(CuratedSchemaEvent.parse(event(kind = 30023))) + } + + @Test + fun `the identifier and the title are forced in whatever the schema says`() { + // A schema fetched from a hostile relay cannot talk a client into + // accepting untitled or unaddressable entries. + val onlyAYear = arrayOf( + arrayOf("d", "years"), + arrayOf("title", "A year"), + arrayOf("name", "Years"), + arrayOf("description", "Just years."), + arrayOf("visibility", "public"), + arrayOf("field", "year", "year", "optional", "", "", "{}"), + ) + + val schema = assertNotNull(CuratedSchemaEvent.parse(event(tags = onlyAYear))) + + assertEquals(listOf("d", "title", "year"), schema.fields.map { it.tag() }) + assertTrue(schema.fields[0].isRequired) + assertTrue(schema.fields[1].isRequired) + assertEquals(CuratedField.IDENTIFIER, schema.fields[0]) + assertEquals(CuratedField.TITLE, schema.fields[1]) + + // And a title the publisher relaxed is made required again, in place. + val relaxed = replacing( + "field", + "title", + arrayOf("field", "title", "text", "optional", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}") + ) + val repaired = assertNotNull(CuratedSchemaEvent.parse(event(tags = relaxed))) + assertEquals(9, repaired.fields.size) + assertTrue(repaired.fields.first { it.name == "title" }.isRequired) + + // Running it again changes nothing. + assertEquals(repaired, repaired.normalized()) + } + + @Test + fun `a require-any naming fewer than two fields says nothing`() { + val schema = assertNotNull( + CuratedSchemaEvent.parse( + event( + tags = bitcoinMov.plus( + listOf( + arrayOf("require-any", "image"), + arrayOf("require-any"), + arrayOf("require-any", "year", " ", "type"), + ) + ) + ) + ) + ) + + assertEquals(listOf(listOf("watchUrl", "imdbUrl"), listOf("year", "type")), schema.requireAny) + } + + @Test + fun `suggesters are the p tags that are pubkeys`() { + val other = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + val schema = assertNotNull( + CuratedSchemaEvent.parse( + event( + tags = replacing("visibility", "closed").plus( + listOf( + arrayOf("p", other), + arrayOf("p", other.uppercase()), + arrayOf("p", "not a pubkey"), + ) + ) + ) + ) + ) + + assertEquals(listOf(other), schema.suggesters) + } + + @Test + fun `the coordinate is what a suggestion replies to`() { + assertEquals("31889:$curator:bitcoin.mov", CuratedSchemaEvent.coordinate(curator, "bitcoin.mov")) + + // An identifier may itself hold colons, so the coordinate does too. + assertEquals("31889:$curator:imdb:tt2821314", CuratedSchemaEvent.coordinate(curator, "imdb:tt2821314")) + } + + @Test + fun `an empty config is written as an empty object`() { + assertEquals("{}", CuratedFieldConfig().toJson()) + assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", CuratedField.IDENTIFIER.config.toJson()) + } + + private fun event( + tags: Array> = bitcoinMov, + content: String = description, + kind: Int = CuratedSchemaEvent.KIND, + ): Event = Event( + id = "a".repeat(64), + pubKey = curator, + createdAt = 1735689600, + kind = kind, + tags = tags, + content = content, + sig = "b".repeat(128), + ) + + private fun without(tag: String): Array> = bitcoinMov.without(tag) + + private fun replacing(tag: String, value: String): Array> = bitcoinMov.replacing(tag, value) + + private fun replacing(tag: String, name: String, with: Array): Array> = + bitcoinMov.map { if (it[0] == tag && it[1] == name) with else it }.toTypedArray() + + private fun Array>.without(tag: String): Array> = + filter { it[0] != tag }.toTypedArray() + + private fun Array>.replacing(tag: String, value: String): Array> = + map { if (it[0] == tag) arrayOf(tag, value) else it }.toTypedArray() +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt new file mode 100644 index 00000000..342753a0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt @@ -0,0 +1,266 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel +import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * What the schema editor puts in front of a member, in the two states it opens in. + * + * **A new list starts filled in.** The two fields every list has and the group's + * own relays are on the form before anything is typed, because they are what the + * group will sign whether or not the member adds to them -- and a form that + * showed an empty list and then signed two fields would be lying about what it + * proposed. + * + * **An existing list opens as an edit.** Its identifier is locked and the form + * says why, its rules show which fields they name, and the suggesters a closed + * list admits are there to be read and removed. The rows are what a member + * checks before proposing, and each is an intention a later change to the + * `LazyColumn` could drop without anything else noticing. + * + * **The sheet offers a type its own settings.** A url field gets "https only" + * and a year field does not, because a setting the event gives no meaning to + * would be written into the schema and mean nothing. + */ +@OptIn(ExperimentalTestApi::class) +class EditGroupCuratedSchemaScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + private val films = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle, with a link to watch each one.", + visibility = CuratedVisibility.Closed, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField( + name = "watchUrl", + type = CuratedFieldType.Url, + isRequired = false, + label = "Where to watch", + config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500), + ), + CuratedField( + name = "imdbUrl", + type = CuratedFieldType.Url, + isRequired = false, + label = "IMDb page", + config = CuratedFieldConfig(tag = "r", marker = "imdb", max = 500), + ), + ), + requireAny = listOf(listOf("watchUrl", "imdbUrl")), + suggesters = listOf(suggester), + relays = listOf("wss://relay.one.example"), + ) + + @Test + fun `a new list starts with the two fields every list has and the group's relays`() = render( + state(existing = null, defaultRelays = listOf("wss://relay.one.example/")) + ) { + // By their supporting line: "Identifier" is also the label over the + // identifier field above, and the row is the one that says what it is. + onNodeWithText("identifier · Token · Required · Derived").assertIsDisplayed() + onNodeWithText("title · Text · Required").assertIsDisplayed() + onNodeWithText("wss://relay.one.example/").assertIsDisplayed() + + onNodeWithText("Public").assertIsSelected() + // A public list admits anyone, so there is no list of who it admits. + onNodeWithText("Suggesters").assertDoesNotExist() + + onNodeWithText("Add field").assertIsDisplayed() + onNodeWithText("Add rule").assertIsDisplayed() + onNodeWithText("Propose schema").assertIsDisplayed() + + // Nothing locks a new identifier: the member is choosing it. + onNodeWithText("The identifier names this list in every reply", substring = true) + .assertDoesNotExist() + } + + @Test + fun `an existing list opens as an edit with its identifier locked`() = render( + state(existing = existing(films)) + ) { + onNodeWithText("films").assertIsDisplayed() + onNodeWithText("The identifier names this list in every reply", substring = true) + .assertIsDisplayed() + + onNodeWithText("Closed").assertIsSelected() + + // Every field, in the order it will be written. + val identifier = onNodeWithText("identifier · Token · Required · Derived").getBoundsInRoot().top + val watch = onNodeWithText("Where to watch").getBoundsInRoot().top + val imdb = onNodeWithText("IMDb page").getBoundsInRoot().top + assertTrue(identifier < watch && watch < imdb, "the fields were drawn out of order") + + // The rule names its fields, and the suggesters are readable as npubs. + onNodeWithText("At least one of").assertIsDisplayed() + onNodeWithText("Suggesters").assertIsDisplayed() + onNodeWithText(suggester.hexToNpubHrp()).assertIsDisplayed() + onNodeWithText("wss://relay.one.example").assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key is told so and the button is inert`() = render( + state(existing = existing(films), canSign = false) + ) { + onNodeWithText("This group has no shared key, so it cannot sign a curated schema.", substring = true) + .assertIsDisplayed() + onNodeWithContentDescription("Propose schema").assertIsNotEnabled() + } + + @Test + fun `the sheet offers a type its own settings and pins a mandatory field`() { + // A url field: https is a thing it can be told, options are not. + renderSheet(field = films.fields[2], index = 2, isMandatory = false) { + onNodeWithText("Edit field").assertIsDisplayed() + onNodeWithText("Url").assertIsSelected() + onNodeWithText("https only").assertIsDisplayed() + onNodeWithText("Options").assertDoesNotExist() + onNodeWithText("Minimum value").assertDoesNotExist() + onNodeWithText("Remove field").assertIsDisplayed() + + // Changing the type changes what is offered. + onNodeWithText("One of a list").performClick() + onNodeWithText("Options").assertIsDisplayed() + onNodeWithText("https only").assertDoesNotExist() + } + + // The identifier: required whatever the switch says, and not removable. + renderSheet(field = films.fields[0], index = 0, isMandatory = true) { + onNodeWithContentDescription("An entry must have this field").assertIsOn() + onNodeWithContentDescription("An entry must have this field").assertIsNotEnabled() + onNodeWithText("Remove field").assertDoesNotExist() + } + } + + private fun existing(schema: CuratedSchema) = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, + content = schema.description, + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = schema.normalized(), + ) + + private fun state( + existing: GroupCuratedSchema?, + defaultRelays: List = emptyList(), + canSign: Boolean = true, + ) = EditGroupCuratedSchemaUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + existing = existing, + defaultRelays = defaultRelays, + canSign = canSign, + ) + + /** + * The screen at a phone's width and a window tall enough to compose the whole + * of it, since a `LazyColumn` does not lay out what it would not show. + */ + private fun render( + uiState: EditGroupCuratedSchemaUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 3000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + EditGroupCuratedSchemaScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + identifier = (uiState as? EditGroupCuratedSchemaUIState.Loaded) + ?.existing?.identifier, + initialEditGroupCuratedSchemaUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } + } + } + + assertions() + } + + /** + * The sheet's content on its own: a bottom sheet is a popup with its own + * window, which a layout test cannot reach into. + */ + private fun renderSheet( + field: CuratedField, + index: Int, + isMandatory: Boolean, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + FieldEditorSheetContent( + edit = EditGroupCuratedSchemaViewModel.FieldEdit(index = index, field = field), + isMandatory = isMandatory, + onCommit = { null }, + onRemove = {} + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index fdf7711e..ce40ccec 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -9,17 +9,23 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.runDesktopComposeUiTest import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelay import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository @@ -189,6 +195,63 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Add post").assertIsDisplayed() } + @Test + fun `the curated schemas sit under the posts and above the group's work`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + posts = listOf(post("Something the group said", 1_700_000_000)), + schemas = listOf( + schema("films", "Films worth translating", 1_700_000_900), + schema("books", "Books worth translating", 1_700_000_000) + ) + ) + ) { + val posts = onNodeWithText("Posts").getBoundsInRoot().top + val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top + val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top + + assertTrue(posts < schemas, "the curated schemas climbed above the posts") + assertTrue(schemas < subgroups, "the curated schemas fell below the group's work") + + // Most recently signed first, one card per list. + val newest = onNodeWithText("Films worth translating").getBoundsInRoot().top + val oldest = onNodeWithText("Books worth translating").getBoundsInRoot().top + assertTrue(newest < oldest, "the schemas were drawn oldest first") + + // What an entry asks for, and who may suggest one. + onAllNodesWithText("Fields: Identifier, Title", substring = true).assertCountEquals(2) + onAllNodesWithText("Public · ", substring = true).assertCountEquals(2) + onNodeWithText("Add schema").assertIsDisplayed() + } + + @Test + fun `a group that curates nothing says so, and offers to add a schema`() = render( + state(groupNostrProfile = profile, relayLists = signedRelayLists()) + ) { + onNodeWithText("Curated schemas").assertIsDisplayed() + onNodeWithText("This group hasn't published a curated schema yet.", substring = true) + .assertIsDisplayed() + onNodeWithText("Add schema").assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key reads the schemas and is offered no editor`() = + render( + state( + groupNostrProfile = profile, + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), + canEditNostrProfile = false + ) + ) { + // A schema exists to be read by people who were never in the room, so + // a member without a share reads it like anybody else and simply + // cannot propose a revision -- neither a new one nor an edit. + onNodeWithText("Films worth translating").assertIsDisplayed() + onNodeWithText("Add schema").assertDoesNotExist() + onNodeWithContentDescription("Edit schema").assertDoesNotExist() + } + @Test fun `a group that has said nothing says so, and offers to say something`() = render( state(groupNostrProfile = null) @@ -218,12 +281,14 @@ class GroupNostrProfileSectionJvmTest { .assertDoesNotExist() onNodeWithText("Edit Nostr profile").assertDoesNotExist() - // The relay lists and the posts go with it: all three describe a nostr - // identity this room does not have. + // The relay lists, the posts and the schemas go with it: all four + // describe a nostr identity this room does not have. onNodeWithText("Relays").assertDoesNotExist() onNodeWithText("Edit relays").assertDoesNotExist() onNodeWithText("Posts").assertDoesNotExist() onNodeWithText("Add post").assertDoesNotExist() + onNodeWithText("Curated schemas").assertDoesNotExist() + onNodeWithText("Add schema").assertDoesNotExist() // The rest of the screen is untouched, so the section's absence is an // absence rather than a screen that failed to draw. @@ -276,10 +341,37 @@ class GroupNostrProfileSectionJvmTest { ) ) + /** + * A list the group curates, built rather than read out of a signed event: + * the reading checks a real signature, and this test is about the screen. + */ + private fun schema(identifier: String, name: String, createdAt: Long) = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'c'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + schema = CuratedSchema( + identifier = identifier, + title = "$name suggestion", + name = name, + description = "Things the group would translate, with a link to each.", + visibility = CuratedVisibility.Public, + fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), + relays = listOf("wss://relay.one.example") + ) + ) + private fun state( groupNostrProfile: GroupNostrProfile?, relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), posts: List = emptyList(), + schemas: List = emptyList(), canEditNostrProfile: Boolean = true, mlsGroupState: String? = "state" ) = ChatRoomDetailUIState.Loaded( @@ -309,6 +401,7 @@ class GroupNostrProfileSectionJvmTest { groupNostrProfile = groupNostrProfile, groupRelayLists = relayLists, groupPosts = posts, + groupCuratedSchemas = schemas, canEditNostrProfile = canEditNostrProfile ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt new file mode 100644 index 00000000..49f8943f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt @@ -0,0 +1,382 @@ +package press.mantra.compose.ui.view.model + +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSchemaProblem +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The decisions the schema editor makes that nothing else can check. + * + * **What a new list starts as.** The two fields every list has and the group's + * own relays, so that what the member sees is what the group will sign rather + * than what `CuratedSchema.normalized` will quietly add. + * + * **What cannot be taken away.** A field writing to `d` or `title` cannot be + * removed, because the event would only grow it back -- so the editor has to + * refuse rather than let a member believe they removed it. + * + * **What counts as a change.** The button proposes only when the schema differs + * from the one the group signed, and getting that wrong is quiet in both + * directions: too eager and every visit re-dates a decision the group did not + * make; too shy and an edit is dropped on a screen that said it had been sent. + */ +class EditGroupCuratedSchemaViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val films = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Closed, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "watch")), + CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "imdb")), + ), + requireAny = listOf(listOf("watchUrl", "imdbUrl")), + suggesters = listOf("e".repeat(64)), + relays = listOf("wss://relay.one.example"), + ) + + private fun viewModel(identifier: String? = null) = EditGroupCuratedSchemaViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = "a".repeat(64), + relayHint = null, + identifier = identifier, + initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loading, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + ) + + private val localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ) + + /** The schema as the group signed it. Not verified here: the editor does not. */ + private fun existing(schema: CuratedSchema = films) = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, + content = schema.description, + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = schema.normalized(), + ) + + private fun newSchema(defaultRelays: List = listOf("wss://relay.one.example")) = + EditGroupCuratedSchemaUIState.Loaded( + localChatRoom = localChatRoom, + existing = null, + defaultRelays = defaultRelays, + canSign = true, + ) + + private fun editing(schema: CuratedSchema = films) = EditGroupCuratedSchemaUIState.Loaded( + localChatRoom = localChatRoom, + existing = existing(schema), + defaultRelays = listOf("wss://relay.two.example"), + canSign = true, + ) + + @Test + fun `a new schema starts with the two fields every list has and the group's relays`() { + val viewModel = viewModel() + viewModel.seedWorkingCopy(newSchema()) + + assertEquals(listOf("d", "title"), viewModel.fields.map { it.tag() }) + assertTrue(viewModel.fields.all { it.isRequired }) + assertEquals(CuratedVisibility.Public, viewModel.visibility) + assertEquals(listOf("wss://relay.one.example"), viewModel.relays) + assertEquals(emptyList(), viewModel.requireAny) + assertEquals(emptyList(), viewModel.suggesters) + } + + @Test + fun `an existing schema starts as itself, and its relays are its own`() { + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + + assertEquals(films.fields, viewModel.fields.toList()) + assertEquals(films.requireAny, viewModel.requireAny.toList()) + assertEquals(films.suggesters, viewModel.suggesters.toList()) + assertEquals(CuratedVisibility.Closed, viewModel.visibility) + // Not the defaults the state also carries: those are for a new list. + assertEquals(listOf("wss://relay.one.example"), viewModel.relays) + } + + @Test + fun `seeding again leaves an edit alone`() { + // The effect that seeds is keyed on a state the view model can re-emit, so + // running twice has to be harmless -- otherwise a reload halfway through an + // edit throws the edit away. + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + assertTrue(viewModel.addRelay("wss://relay.two.example")) + + viewModel.seedWorkingCopy(editing()) + + // The added relay is normalized, which is how the group's other relay + // lists are written; the signed one is kept as the group wrote it. + assertEquals(listOf("wss://relay.one.example", "wss://relay.two.example/"), viewModel.relays) + } + + @Test + fun `a relay already signed into the schema is a duplicate with or without the slash`() { + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + + assertFalse(viewModel.addRelay("wss://relay.one.example")) + assertFalse(viewModel.addRelay("wss://relay.one.example/")) + assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure) + assertEquals(listOf("wss://relay.one.example"), viewModel.relays) + } + + @Test + fun `a mandatory field cannot be removed and another one can`() { + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + + assertTrue(viewModel.isMandatory(viewModel.fields[0])) + assertTrue(viewModel.isMandatory(viewModel.fields[1])) + assertFalse(viewModel.isMandatory(viewModel.fields[2])) + + viewModel.removeField(0) + viewModel.removeField(1) + assertEquals(listOf("identifier", "title", "watchUrl", "imdbUrl"), viewModel.fields.map { it.name }) + + // A field that goes takes its place in the rules with it: a rule naming a + // field that no longer exists would silently never be satisfied. + viewModel.removeField(2) + assertEquals(listOf("identifier", "title", "imdbUrl"), viewModel.fields.map { it.name }) + assertEquals(listOf(listOf("imdbUrl")), viewModel.requireAny.toList()) + } + + @Test + fun `renaming a field follows through to the rules that named it`() { + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + + viewModel.startEditingField(2) + assertNull(viewModel.commitField(viewModel.fields[2].copy(name = "watch"))) + + assertEquals(listOf("identifier", "title", "watch", "imdbUrl"), viewModel.fields.map { it.name }) + assertEquals(listOf(listOf("watch", "imdbUrl")), viewModel.requireAny.toList()) + assertNull(viewModel.editingField, "the sheet stayed open after a commit") + } + + @Test + fun `a field name is one word and belongs to one field`() { + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + + viewModel.startAddingField() + val draft = viewModel.editingField!!.field + + assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameMissing, viewModel.commitField(draft.copy(name = " "))) + assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameHasSpaces, viewModel.commitField(draft.copy(name = "watch url"))) + assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameTaken, viewModel.commitField(draft.copy(name = "imdbUrl"))) + assertEquals(4, viewModel.fields.size, "a refused field went in anyway") + + assertNull(viewModel.commitField(draft.copy(name = " year "))) + assertEquals("year", viewModel.fields.last().name) + + // Editing a field back to its own name is not taking it from anybody. + viewModel.startEditingField(4) + assertNull(viewModel.commitField(viewModel.fields[4].copy(label = "Year"))) + } + + @Test + fun `a rule with fewer than two names is dropped from the draft rather than refused`() { + val viewModel = viewModel() + viewModel.seedWorkingCopy(newSchema()) + + viewModel.addRequireAnyGroup() + viewModel.toggleRequireAny(0, "title") + viewModel.addRequireAnyGroup() + + val draft = viewModel.draft("d", "t", "n", "desc", "", "") + + assertEquals(2, viewModel.requireAny.size, "the editor lost a rule being built") + assertEquals(emptyList(), draft.requireAny) + } + + @Test + fun `a pubkey is an npub or hex and nothing else`() { + val hex = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + // Encoded rather than typed, so the checksum is right by construction. + val npub = hex.hexToNpubHrp() + + assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(hex)) + assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(" ${hex.uppercase()} ")) + assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(npub)) + assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull("nostr:$npub")) + + listOf("", "npub1notanpub", hex.dropLast(1), "group@example.com", "nsec1" + npub.drop(5)).forEach { + assertNull(EditGroupCuratedSchemaViewModel.pubkeyOrNull(it), "\"$it\" was taken for a pubkey") + } + + val viewModel = viewModel() + viewModel.seedWorkingCopy(newSchema()) + assertFalse(viewModel.addSuggester("group@example.com")) + assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.NotAPubkey, viewModel.addSuggesterFailure) + assertTrue(viewModel.addSuggester(npub)) + assertFalse(viewModel.addSuggester(hex)) + assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.AlreadyListed, viewModel.addSuggesterFailure) + assertEquals(listOf(hex), viewModel.suggesters) + } + + @Test + fun `a url that is not a relay is refused and named`() { + val viewModel = viewModel() + viewModel.seedWorkingCopy(newSchema(defaultRelays = emptyList())) + + assertFalse(viewModel.addRelay("http://relay.one.example")) + assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.NotARelay, viewModel.addRelayFailure) + // The NIP allows ws://; what the group signs is held to the app's rule. + assertFalse(viewModel.addRelay("ws://relay.one.example")) + + assertTrue(viewModel.addRelay("wss://relay.one.example")) + assertFalse(viewModel.addRelay("wss://relay.one.example")) + assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure) + } + + @Test + fun `opening a schema and pressing the button proposes nothing`() { + // The case that has to be silent. A member opening the editor, reading it + // and pressing the button must not spend a quorum on an identical schema. + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + + var outcome = "" + viewModel.proposeSchema( + localChatRoom = localChatRoom, + existing = existing(), + identifier = films.identifier, + title = films.title, + name = films.name, + description = films.description, + picture = "", + domain = "", + onSuccess = { outcome = "proposed" }, + onInvalid = { outcome = "invalid" }, + onNothingToPropose = { outcome = "nothing" }, + onFailure = { outcome = "failed" }, + ) + + assertEquals("nothing", outcome) + assertEquals(emptyList(), viewModel.problems) + } + + @Test + fun `an edit keeps the identifier whatever the field says`() { + val viewModel = viewModel("films") + viewModel.seedWorkingCopy(editing()) + + var outcome = "" + viewModel.proposeSchema( + localChatRoom = localChatRoom, + existing = existing(), + // A different identifier would be a different list, not an edit. + identifier = "books", + title = films.title, + name = films.name, + description = films.description, + picture = "", + domain = "", + onSuccess = { outcome = "proposed" }, + onInvalid = { outcome = "invalid" }, + onNothingToPropose = { outcome = "nothing" }, + onFailure = { outcome = "failed" }, + ) + + assertEquals("nothing", outcome, "a changed identifier was taken as an edit") + } + + @Test + fun `a schema a reader would refuse is refused here, and the reasons are kept`() { + val viewModel = viewModel() + viewModel.seedWorkingCopy(newSchema()) + + var outcome = "" + viewModel.proposeSchema( + localChatRoom = localChatRoom, + existing = null, + identifier = " ", + title = "t".repeat(201), + name = "Films", + description = "", + picture = "http://example.com/x.png", + domain = "not a domain", + onSuccess = { outcome = "proposed" }, + onInvalid = { outcome = "invalid" }, + onNothingToPropose = { outcome = "nothing" }, + onFailure = { outcome = "failed" }, + ) + + assertEquals("invalid", outcome) + assertEquals( + listOf( + CuratedSchemaProblem.IdentifierMissing, + CuratedSchemaProblem.TitleTooLong, + CuratedSchemaProblem.DescriptionMissing, + CuratedSchemaProblem.PictureNotHttps, + CuratedSchemaProblem.DomainInvalid, + ), + viewModel.problems, + ) + } + + @Test + fun `the draft trims what was typed and normalizes the domain`() { + val viewModel = viewModel() + viewModel.seedWorkingCopy(newSchema()) + + val draft = viewModel.draft( + identifier = " films ", + title = " Film suggestion ", + name = " Films ", + description = " Films. ", + picture = " ", + domain = " https://Example.COM/ ", + ) + + assertEquals("films", draft.identifier) + assertEquals("Film suggestion", draft.title) + assertEquals("Films", draft.name) + assertEquals("Films.", draft.description) + assertNull(draft.picture) + assertEquals("example.com", draft.domain) + assertTrue(draft.isUsable()) + } +} From 26de13b0100df900c49f5c1a5ff5562451bcdf05 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 11 Sep 2026 16:58:59 +0200 Subject: [PATCH 11/71] feat(groups): an unsigned event, pasted, and the promise that it lands where its kind says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group's identity block has a typed editor for each thing the group signs about itself -- the profile form, the post composer, the relay editor, the schema editor -- and each builds one kind of event. This is the untyped way in: an unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to the quorum like anything else, and filed by kind once it is signed. A pasted kind 0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind 10002 the general relay list. The entry is "Propose event", last in the identity block, behind the same share-holder gate as every button in it and absent from a NIP-17 room like the block itself. The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov repo prints exactly the kind 31889 event it would publish, id, pubkey, signature and all; until now the only way to get that list under a group's key was to retype it field by field into the editor. Now it is pasted, checked, and signed. **Where the event lands is decided by nothing in this change, and that is the design.** `FrostSigningManager.complete` files every signed event as a `GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists and schemas off those rows by kind, each reader verifying the room's signature for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the profile form does, because by the time either is signed there is no telling them apart. A second path -- a switch on kind that wrote the pasted event into the right place -- would have been a second reading of the same rows, and two readings of one signature drift. So the persistence half of this feature is a test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1, kind 31889 and kind 10002 each through a real FROST quorum signature, in the shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`, `GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out, under the group's key and coordinate rather than the pasted one. **What the paste says about its author and its time is ignored, and the screen says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`: what goes to `proposeSigning` is the kind, the tags and the content, and the session resolves the room's key, hashes the id over it and stamps the time it opens at -- which is what every typed editor does too. `created_at` in particular had to go. Every kind here is replaceable or addressable, so a pasted timestamp older than the group's last event would make the new one *lose* to the old on every reader and the proposal would look as though it had done nothing. The explanatory line under the byline names both facts, because a paste with a `pubkey` in it is the one case where a member could reasonably expect otherwise. **Only kinds the screen has a place for, and only events that place would accept -- checked before the quorum is asked, not after.** A signature is the most expensive thing this app does, and the readers refuse rather than repair: a kind 0 whose content is not a profile, a blank kind 1, a kind 31889 with no visibility would each be signed, filed and shown nowhere, with nothing to tell the member their quorum was spent on nothing. So `GroupEventProposal.read` makes every check a reader makes, on the paste, and refuses with the reader's own reason -- the schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of kinds with a section on the group's screen: 0, 1, the four relay lists and 31889. Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate or a key state event has invariants a paste cannot be trusted to meet and a place in the room's life that a form is not, and the nip30303 kinds have editors of their own. A long-form article is a perfectly good event the group could sign; it is refused because the line is "has somewhere to land", and the refusal names the kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing groups are entitled to do, and an empty relay list is accepted, since it is a withdrawal the relays card shows as "no relays" rather than as nothing -- the same two lines `GroupNostrProfile.of` and `GroupRelayList` already draw. **The preview is the signing screen's own words.** Above the paste is what the group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1 field" -- read live on every change, since a paste is a few kilobytes and one JSON parse is cheaper than a debounce that would hide the answer for a beat. It is `ProposedEvent.summarize` on the reading, not a description of this screen's own, so that what a member reads before proposing is word for word what every other member reads before signing. Two descriptions of one event would drift. **And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay lists.** The three features before this one added none for their kinds, so a member asked to sign the group's profile has been shown "Event of kind 0" over a line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A profile is now said by its name and what it says about itself, with "An empty profile" and "Unreadable profile" kept apart because a signer should know which; a post by its words, the same call the translated-passage arm makes; a relay list by which of the four it is and its hosts, or "No relays" for a withdrawal. `ProposedEventTest` is new and pins all of them, the schema arm from the last commit included, and that an unrecognised kind keeps its number -- refusing to describe an event is better than describing it wrongly. **The paste stays on every refusal.** A refused reading is drawn under the field and the button, pressed anyway, is answered rather than ignored; a failed proposal is a snackbar over the same paste. The JSON is the thing worth keeping, and a member who pasted two kilobytes of schema should not have to find them again because a visibility tag was missing. The field is set in a monospace face for the same reason: it is JSON, and a bracket that does not line up is what a member is looking for in it. `isActionPending` guards a double press, because a pasted kind 1 accumulates like any other post and a second session over the same paste is a second post on the record forever. **The byline is the group's**, as on the post composer and for its reason: whatever is pasted goes out in the group's name, and a paste naming some other `pubkey` goes out in the group's name anyway. 23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and what is dropped, junk named as junk, the accepted set, an unreadable profile against an empty one, a blank post, a schema refused with the reader's reasons, a relay list with or without relays -- and the four signed chains that are this commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer. `ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview, the two refusals and the inert button for a member with no share, and two more cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block and take it away from a member with no share and from a NIP-17 room. 1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@392b90b8d508b0faf1da8ef304c74eefca6aa049 --- .../composeResources/values/strings.xml | 17 + .../compose/nostr/GroupEventProposal.kt | 188 +++++++ .../mantra/compose/text/ProposedEvent.kt | 36 ++ .../ui/composable/ChatRoomDetailScreen.kt | 41 ++ .../EditGroupCuratedSchemaScreen.kt | 2 +- .../ui/composable/ProposeGroupEventScreen.kt | 465 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 22 + .../routes/ProposeGroupEventRoute.kt | 11 + .../view/model/ProposeGroupEventViewModel.kt | 158 ++++++ .../ui/view/state/ProposeGroupEventUIState.kt | 32 ++ .../compose/nostr/GroupEventProposalTest.kt | 338 +++++++++++++ .../mantra/compose/text/ProposedEventTest.kt | 105 ++++ .../GroupNostrProfileSectionJvmTest.kt | 24 + .../ProposeGroupEventScreenJvmTest.kt | 116 +++++ 14 files changed, 1554 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 16d2b935..14648578 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -559,4 +559,21 @@ Year Duration in seconds Number + Propose event + Paste an unsigned Nostr event for the group to sign. It is filed by kind: a profile (kind 0), a post (kind 1), a relay list, or a curated schema (kind 31889). + The group signs it, so it takes a quorum. The author is the group and the time is now, whatever the paste says about either. + This group has no shared key, so it cannot sign an event. Run a shared key ceremony first. + Couldn't ask the group to sign this event. + Unsigned event + {"kind": 1, "tags": [], "content": "…"} + What the group would sign + Kind %1$s + That isn't JSON. + An event is a JSON object with a kind, tags and content. + An event needs a kind. + Kind %1$s has nowhere to go on the group's screen. It can file kinds %2$s. + Tags must be a list of lists of strings. + Content must be text. + The content isn't a profile. A kind 0 carries a JSON object of profile fields. + This schema can't be signed as it is: diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt new file mode 100644 index 00000000..52c1ca3c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt @@ -0,0 +1,188 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.network.serialization.CommonJson +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSchemaProblem +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull + +/** + * An unsigned event a member pasted for the group to sign, read and checked + * before a quorum is asked for it. + * + * The typed editors -- the profile form, the post composer, the relay and + * schema editors -- each build one kind of event and know what it needs. This + * is the untyped way in: the event as JSON, the way another tool prints it + * (`npm run schema:dry` in the bitcoin.mov repo prints exactly the schema event + * it would publish), pasted whole. What comes out is the same thing the editors + * produce, a kind, tags and content for `FrostSigningRepository.proposeSigning`, + * and it is filed the same way when the quorum signs: `FrostSigningManager.complete` + * records it as a `GroupSignedEvent` and the group's screen reads its profile, + * posts, relay lists and schemas straight off those by kind. + * + * ### What is ignored + * + * `id`, `pubkey`, `sig` and `created_at`. The group is the author -- the + * session resolves the room's key and hashes the id over it -- and the time is + * when the session opens, which is what every typed editor does too. A pasted + * `created_at` in particular has to go: the kinds here are replaceable or + * addressable, so a stale one would make the new event *older* than the last and + * the proposal would look as though it did nothing. The screen says so. + * + * ### What is refused, and why before rather than after + * + * Only kinds the group's screen has a place for, and only events that place + * would accept. A signature is the most expensive thing this app does, and the + * readers refuse rather than repair: a kind 0 whose content is not a profile, + * a blank post, a schema with no visibility would each be signed, filed and + * shown nowhere, with nothing to tell the member their quorum was spent on + * nothing. So every check a reader makes is made here first, on the paste. + */ +object GroupEventProposal { + /** + * The kinds a pasted event may be: exactly the ones the group's screen reads. + * + * Not the kinds `ChatMessage.applyInnerEvent` has an arm for. A subgroup + * certificate or a key state event has invariants a paste cannot be trusted + * to meet and a place in the room's life that a form is not, and the + * nip30303 kinds have editors of their own. The line is "has a section on the + * group's screen", because that is where a member goes to see it landed. + */ + val ACCEPTED_KINDS: Set = + setOf(MetadataEvent.KIND, TextNoteEvent.KIND, CuratedSchemaEvent.KIND) + GroupRelaySet.KINDS + + sealed interface Reading + + /** An event the group could sign and the screen could show. */ + class Ready( + val kind: Kind, + val tags: Array>, + val content: String, + ) : Reading { + /** + * The event as the signing screen will describe it, for a preview before + * the button is pressed. The id, author and signature are placeholders: + * `ProposedEvent.summarize` reads none of them. + */ + fun preview(): Event = Event( + id = "", + pubKey = "", + createdAt = 0, + kind = kind, + tags = tags, + content = content, + sig = "", + ) + } + + /** Why the paste cannot be proposed as it is. */ + sealed interface Refused : Reading { + data object NotJson : Refused + + /** JSON, but an array, a string, a number -- anything but an object. */ + data object NotAnObject : Refused + + data object NoKind : Refused + + data class KindNotAccepted(val kind: Kind) : Refused + + /** `tags` present but not a list of lists of strings. */ + data object TagsMalformed : Refused + + /** `content` present but not a string. */ + data object ContentNotText : Refused + + /** A kind 0 whose content will not parse as a profile. */ + data object ProfileUnreadable : Refused + + /** A kind 1 with nothing to say, which the posts reader would drop. */ + data object PostBlank : Refused + + /** A kind 31889 the schema reader would refuse, with its reasons. */ + data class SchemaUnusable(val problems: List) : Refused + } + + private const val KEY_KIND = "kind" + private const val KEY_TAGS = "tags" + private const val KEY_CONTENT = "content" + + /** The paste as something the group could sign, or the reason it is not. */ + fun read(json: String): Reading { + val element = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() + ?: return Refused.NotJson + + val obj = element as? JsonObject ?: return Refused.NotAnObject + + // A number or a numeric string: relays write the former and a hand + // types either, and there is nothing to be gained by refusing "1". + val kind = (obj[KEY_KIND] as? JsonPrimitive)?.intOrNull ?: return Refused.NoKind + if (kind !in ACCEPTED_KINDS) return Refused.KindNotAccepted(kind) + + val tags = when (val raw = obj[KEY_TAGS]) { + null -> emptyArray() + else -> readTags(raw) ?: return Refused.TagsMalformed + } + + val content = when (val raw = obj[KEY_CONTENT]) { + null -> "" + else -> (raw as? JsonPrimitive)?.takeIf { it.isString }?.content + ?: return Refused.ContentNotText + } + + return when (kind) { + // An empty kind 0 is a profile the group has wiped, which it is + // entitled to do; content that is not JSON is not a profile at all. + // The same line `GroupNostrProfile.of` draws. + MetadataEvent.KIND -> if (metadataOf(tags, content) == null) { + Refused.ProfileUnreadable + } else { + Ready(kind, tags, content) + } + + // The line `AddGroupPostViewModel.proposePost` draws, for the reason + // it gives: a blank post cannot be told from a broken one and, kind:1 + // not being replaceable, cannot be un-said. + TextNoteEvent.KIND -> if (content.isBlank()) { + Refused.PostBlank + } else { + Ready(kind, tags, content) + } + + CuratedSchemaEvent.KIND -> CuratedSchemaEvent.read(tags, content).problems().let { problems -> + if (problems.isEmpty()) Ready(kind, tags, content) else Refused.SchemaUnusable(problems) + } + + // A relay list with no relay in it is a list the group has withdrawn, + // which the relays card shows as "no relays" rather than as nothing. + else -> Ready(kind, tags, content) + } + } + + private fun readTags(raw: JsonElement): Array>? { + val outer = raw as? JsonArray ?: return null + + return outer.map { tag -> + val inner = tag as? JsonArray ?: return null + inner.map { value -> + (value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull ?: return null + }.toTypedArray() + }.toTypedArray() + } + + private fun metadataOf(tags: Array>, content: String) = MetadataEvent( + id = "", + pubKey = "", + createdAt = 0, + tags = tags, + content = content, + sig = "", + ).contactMetaData() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 523fca38..fe9c10f3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -1,7 +1,10 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -156,6 +159,39 @@ object ProposedEvent { ).joinToString(" · ") ) + // The group's own profile. Named by what the group would be called and + // what it would say about itself, which is the whole of what is being + // decided; the JSON in the content is the one thing a signer should not + // have to read to find that out. + MetadataEvent.KIND -> Summary( + label = "Nostr profile", + detail = MetadataEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).contactMetaData()?.let { metadata -> + listOfNotNull( + metadata.anyName()?.takeIf { it.isNotBlank() }, + metadata.about?.takeIf { it.isNotBlank() } + ).joinToString(" · ").ifEmpty { "An empty profile" } + } ?: "Unreadable profile" + ) + + // A post in the group's name. The words are what is being decided, so + // they are the detail rather than a count of them -- the same call the + // translated-passage arm makes. + TextNoteEvent.KIND -> Summary(label = "Post", detail = event.content) + + // One of the group's relay lists, shown as hosts: every entry in one + // starts wss://, so the scheme is the part that never tells two apart. + // An empty one is a list the group is withdrawing, and says so. + in GroupRelaySet.KINDS -> { + val set = GroupRelaySet.entries.first { it.kind == event.kind } + + Summary( + label = "${set.name} relay list", + detail = set.parse(event.tags).joinToString { it.displayUrl() }.ifEmpty { "No relays" } + ) + } + // A list the group would curate. A member signing this is agreeing to // take suggestions from whoever the visibility admits and to be the one // key that accepts them, so the summary is the list's name, who may diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 33dabc79..161f8bf7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Ballot import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks @@ -78,6 +79,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -108,6 +110,7 @@ import mantra.composeapp.generated.resources.curated_schemas import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet import mantra.composeapp.generated.resources.add_schema import mantra.composeapp.generated.resources.fields_colon +import mantra.composeapp.generated.resources.propose_event import mantra.composeapp.generated.resources.posted_on import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet import mantra.composeapp.generated.resources.edit_relays @@ -614,6 +617,44 @@ fun ChatRoomDetailScreen( } } + // The untyped way into everything above it. Each + // section has a form that builds one kind of event; + // this takes any of those kinds as JSON, pasted from + // wherever it was made, and it lands in whichever + // section is its kind's once the quorum signs -- + // the same reading, off the same signed events. + // Last in the block because it is about the block + // rather than about any one section of it, and + // behind the same gate for the same reason. + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ProposeGroupEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.DataObject, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.propose_event)) + } + } + } + item { HorizontalDivider() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt index b3cba3f7..be868d9b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt @@ -875,7 +875,7 @@ private fun CuratedFieldType.label(): StringResource = when (this) { * sentence and the check cannot disagree about the number. */ @Composable -private fun CuratedSchemaProblem.message(): String = when (this) { +internal fun CuratedSchemaProblem.message(): String = when (this) { CuratedSchemaProblem.IdentifierMissing -> stringResource(Res.string.an_identifier_is_required) CuratedSchemaProblem.IdentifierTooLong -> stringResource( Res.string.the_identifier_must_be_at_most_n_characters, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt new file mode 100644 index 00000000..b82b529e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt @@ -0,0 +1,465 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupEventProposal +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.ProposeGroupEventViewModel +import press.mantra.compose.ui.view.state.ProposeGroupEventUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_post_needs_something_to_say +import mantra.composeapp.generated.resources.an_event_is_a_json_object +import mantra.composeapp.generated.resources.an_event_needs_a_kind +import mantra.composeapp.generated.resources.content_must_be_text +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_event +import mantra.composeapp.generated.resources.eg_kind_1_tags_content +import mantra.composeapp.generated.resources.kind_n +import mantra.composeapp.generated.resources.kind_n_has_nowhere_to_go_on_this_screen +import mantra.composeapp.generated.resources.paste_an_unsigned_event_for_the_group_to_sign +import mantra.composeapp.generated.resources.propose_event +import mantra.composeapp.generated.resources.tags_must_be_a_list_of_lists_of_strings +import mantra.composeapp.generated.resources.that_is_not_json +import mantra.composeapp.generated.resources.the_content_is_not_a_profile +import mantra.composeapp.generated.resources.the_group_signs_an_event_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_event +import mantra.composeapp.generated.resources.this_schema_cannot_be_signed_as_it_is +import mantra.composeapp.generated.resources.unsigned_event +import mantra.composeapp.generated.resources.what_the_group_would_sign +import org.jetbrains.compose.resources.stringResource + +/** + * An unsigned event, pasted, for the group to sign. + * + * The typed editors each build one kind of statement; this takes any of the + * kinds the group's screen can show, as JSON, the way another tool prints one. + * Above the paste is what the group would be signing, read back the way the + * signing screen will describe it, so a member sees "Curated schema · bitcoin.mov" + * rather than a wall of brackets before deciding -- and sees why the group will + * not be asked when the paste is something it could never show. + * + * The byline is the group's, as on the post composer and for the same reason: + * whatever is pasted goes out in the group's name, and a paste that names some + * other `pubkey` goes out in the group's name anyway. The explanatory line says + * so, because a paste with a `pubkey` in it is the one case where a member could + * reasonably expect otherwise. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ProposeGroupEventScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialProposeGroupEventUIState: ProposeGroupEventUIState = ProposeGroupEventUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val proposeGroupEventViewModel: ProposeGroupEventViewModel = viewModel( + factory = ProposeGroupEventViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProposeGroupEventUIState = initialProposeGroupEventUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in the click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_event) + + ScreenStateTransition(proposeGroupEventViewModel.proposeGroupEventUIState) { uiState -> + when (val proposeGroupEventUIState = uiState) { + is ProposeGroupEventUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState(message = proposeGroupEventUIState.message, onRetry = null) + } + + is ProposeGroupEventUIState.Loaded -> { + val eventFieldState = rememberTextFieldState() + val canSign = proposeGroupEventUIState.canSign + + // Read on every change. A paste is a few kilobytes at most and the + // reading is one JSON parse, so this is cheaper than the debounce + // that would hide the answer for a beat after the paste. + val json = eventFieldState.text.toString() + val reading = remember(json) { + json.takeIf { it.isNotBlank() }?.let(GroupEventProposal::read) + } + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + // imePadding: this screen is a text field the size of the screen, + // and without it the software keyboard covers what is being pasted. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(text = stringResource(Res.string.propose_event)) } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + proposeGroupEventViewModel.proposeEvent( + localChatRoom = proposeGroupEventUIState.localChatRoom, + json = eventFieldState.text.toString(), + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. Nothing has been + // signed yet -- the event lands when + // a quorum signs -- so landing on a + // screen that does not show it would + // read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // The reason is already under the paste; + // pressing the button anyway is answered + // rather than ignored, and the paste + // stays. Same on a failure to propose. + onRefused = {}, + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon(Icons.Default.Draw, contentDescription = "Propose event") + Text(stringResource(Res.string.propose_event)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Whose name this goes out in, said before anything is + // pasted. It is the group's, whatever the paste says. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileAvatar( + size = MaterialTheme.spacing.space500, + publicKey = chatRoomId, + picture = proposeGroupEventUIState.groupNostrProfile?.picture(), + name = proposeGroupEventUIState.groupNostrProfile?.name() + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + Text( + text = proposeGroupEventUIState.groupNostrProfile?.name() + ?: proposeGroupEventUIState.localChatRoom.chatRoom.subject + ?: chatRoomId.take(16), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.paste_an_unsigned_event_for_the_group_to_sign + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource( + Res.string.the_group_signs_an_event_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_an_event + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + when (reading) { + null -> Unit + is GroupEventProposal.Ready -> ProposalPreview(reading) + is GroupEventProposal.Refused -> RefusalNotice(reading) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = eventFieldState, + // The paste grows into the screen rather than scrolling + // inside three lines, and is set in a monospace face: + // it is JSON, and a bracket that does not line up is + // the kind of thing a member is looking for in it. + lineLimits = TextFieldLineLimits.MultiLine(), + textStyle = TextStyle(fontFamily = FontFamily.Monospace), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + label = { Text(stringResource(Res.string.unsigned_event)) }, + placeholder = { + Text( + text = stringResource(Res.string.eg_kind_1_tags_content), + fontFamily = FontFamily.Monospace + ) + }, + isError = reading is GroupEventProposal.Refused + ) + } + } + } + + ProposeGroupEventUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.propose_event), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialProposeGroupEventUIState == ProposeGroupEventUIState.Loading) { + proposeGroupEventViewModel.initiateProposeGroupEvent() + } + } +} + +/** + * What the group would sign, said the way the signing screen will say it. + * + * `ProposedEvent.summarize` rather than a description of this screen's own, so + * that what a member reads here before proposing is word for word what every + * other member reads before signing. Two descriptions of one event would drift. + */ +@Composable +private fun ProposalPreview(ready: GroupEventProposal.Ready) { + val summary = ProposedEvent.summarize(ready.preview()) + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = stringResource(Res.string.what_the_group_would_sign), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(Res.string.kind_n, ready.kind.toString()) + " · " + summary.label, + style = MaterialTheme.typography.titleSmall + ) + + // Capped, because a kind:1's detail is the whole post and the paste + // under this card is the thing the screen is for. + Text( + text = summary.detail, + style = MaterialTheme.typography.bodyMedium, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** + * Why the group will not be asked, under the paste it is about. + * + * Named rather than silent: a button that does nothing for a paste it refuses is + * indistinguishable from a missed tap. A schema's reasons are the same sentences + * the schema editor uses, since they are the same checks. + */ +@Composable +private fun RefusalNotice(refused: GroupEventProposal.Refused) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = when (refused) { + GroupEventProposal.Refused.NotJson -> stringResource(Res.string.that_is_not_json) + GroupEventProposal.Refused.NotAnObject -> stringResource(Res.string.an_event_is_a_json_object) + GroupEventProposal.Refused.NoKind -> stringResource(Res.string.an_event_needs_a_kind) + is GroupEventProposal.Refused.KindNotAccepted -> stringResource( + Res.string.kind_n_has_nowhere_to_go_on_this_screen, + refused.kind.toString(), + GroupEventProposal.ACCEPTED_KINDS.sorted().joinToString() + ) + GroupEventProposal.Refused.TagsMalformed -> + stringResource(Res.string.tags_must_be_a_list_of_lists_of_strings) + GroupEventProposal.Refused.ContentNotText -> stringResource(Res.string.content_must_be_text) + GroupEventProposal.Refused.ProfileUnreadable -> + stringResource(Res.string.the_content_is_not_a_profile) + GroupEventProposal.Refused.PostBlank -> stringResource(Res.string.a_post_needs_something_to_say) + is GroupEventProposal.Refused.SchemaUnusable -> + stringResource(Res.string.this_schema_cannot_be_signed_as_it_is) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + + if (refused is GroupEventProposal.Refused.SchemaUnusable) { + refused.problems.forEach { problem -> + Text( + text = problem.message(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@ConformancePreviews +@Composable +private fun ProposeGroupEventScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProposeGroupEventScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialProposeGroupEventUIState = ProposeGroupEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // A group that can sign, so the screen renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 773b6dad..b27a0ac2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen import press.mantra.compose.ui.composable.AddGroupPostScreen import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen +import press.mantra.compose.ui.composable.ProposeGroupEventScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen @@ -129,6 +130,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -1057,6 +1059,26 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProposeGroupEventScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the paste so back returns to the group rather + // than to an event whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt new file mode 100644 index 00000000..71f12f23 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** The untyped way into a group's signed statements: an unsigned event, pasted. */ +@Serializable +data class ProposeGroupEventRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt new file mode 100644 index 00000000..87f8f029 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt @@ -0,0 +1,158 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupEventProposal +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.ProposeGroupEventUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Asks the group to sign an event a member pasted. + * + * Nothing is built here; the paste is the event. What this decides is whether + * the group should be asked at all -- `GroupEventProposal.read` refuses a paste + * the group's screen could never show, so a quorum is not spent on an event that + * would be signed, filed and seen by nobody -- and it hands the kind, tags and + * content to the same `proposeSigning` every typed editor uses. The author and + * the time are the session's to fill in, which is how a pasted `pubkey` or + * `created_at` comes to be ignored: they are never passed along. + * + * Where the event lands is decided by nothing here. `FrostSigningManager.complete` + * files every signed event as a `GroupSignedEvent`, and the group's screen reads + * its profile, posts, relay lists and schemas off those by kind. A pasted kind 0 + * becomes the profile the same way a kind 0 from the profile form does, because + * by the time either is signed there is no telling them apart. + */ +class ProposeGroupEventViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialProposeGroupEventUIState: ProposeGroupEventUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var proposeGroupEventUIState: ProposeGroupEventUIState by mutableStateOf( + initialProposeGroupEventUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateProposeGroupEvent() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + proposeGroupEventUIState = if (localChatRoom == null) { + ProposeGroupEventUIState.Error("Couldn't find the chat room") + } else { + ProposeGroupEventUIState.Loaded( + localChatRoom = localChatRoom, + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + // A NIP-17 room has no key of its own to sign as, so it has no + // statements to make -- the same condition the group detail + // screen gates the entry point on. + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Opens a session over the pasted event, or says why not. + * + * [onRefused] carries the reading's own reason, which the screen has been + * showing under the paste all along; it is repeated here so pressing the + * button on a refused paste is answered rather than ignored. Both refusals + * and a failed proposal stay on the paste: the JSON is the thing worth + * keeping. + */ + fun proposeEvent( + localChatRoom: LocalChatRoom, + json: String, + onSuccess: (sessionId: String) -> Unit, + onRefused: (GroupEventProposal.Refused) -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. A pasted kind:1 accumulates like any + // other post, so a second session over the same paste is a second post + // on the record forever. + if (isActionPending.value) return + + val ready = when (val reading = GroupEventProposal.read(json)) { + is GroupEventProposal.Ready -> reading + is GroupEventProposal.Refused -> { + onRefused.invoke(reading) + return + } + } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = ready.kind, + tags = ready.tags, + content = ready.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a pasted kind ${ready.kind} event for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "ProposeGroupEventViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialProposeGroupEventUIState: ProposeGroupEventUIState = + ProposeGroupEventUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProposeGroupEventViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProposeGroupEventUIState = initialProposeGroupEventUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt new file mode 100644 index 00000000..81a2b502 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile + +sealed interface ProposeGroupEventUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * Who the group says it is, for the byline over the paste. + * + * Whatever is pasted goes out signed by the group and read as the group, + * so the screen shows whose name it will carry -- which is not the + * member pasting it, and not whatever `pubkey` the paste may name. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the paste writable and the button inert, for the same + * reason the typed editors do: reading and checking cost nothing, and + * only a share-holder can open the session that would sign it. + */ + val canSign: Boolean = false, + ): ProposeGroupEventUIState + + data class Error( + val message: String + ): ProposeGroupEventUIState + + data object Loading: ProposeGroupEventUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt new file mode 100644 index 00000000..a9beafd9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt @@ -0,0 +1,338 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSchemaProblem +import press.mantra.compose.nostr.curated.CuratedVisibility +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a pasted event may become, and where it goes once the group has signed it. + * + * Two halves. The first is the reading: which pastes are refused and why, and + * that what is refused is exactly what a reader would refuse -- the point of + * checking before proposing is that a quorum is never asked to sign something + * that will be filed and shown nowhere. + * + * The second is the promise the feature makes: a pasted kind 0 becomes the + * profile, a kind 1 a post, a kind 31889 a schema, a relay list a relay list. That + * is not this module's doing -- `FrostSigningManager.complete` files every signed + * event and the group's screen reads each section off those by kind -- so the + * test is the whole chain: the paste read here, signed by a real quorum through + * the same shape `FrostSigningManager.advance` runs, and read back by the reader + * that section uses. + */ +class GroupEventProposalTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + /** + * The bitcoin.mov schema as `npm run schema:dry` would print it: a whole + * event, id, author, signature and time included, abbreviated to the tags + * a reading depends on. + */ + private val pastedSchema = """ + { + "id": "b2f8c7a7b4d5e6f70123456789abcdef0123456789abcdef0123456789abcdef", + "pubkey": "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25", + "created_at": 1735689600, + "kind": 31889, + "tags": [ + ["d", "bitcoin.mov"], + ["title", "bitcoin.mov suggestion"], + ["name", "bitcoin.mov"], + ["description", "Fields for a bitcoin.mov entry."], + ["k", "31888"], + ["visibility", "public"], + ["domain", "bitcoin.mov"], + ["relay", "wss://ephemeral.mantra.press"], + ["field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"], + ["field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"], + ["field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"repeat\":true}"] + ], + "content": "Fields for a bitcoin.mov entry.", + "sig": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00" + } + """.trimIndent() + + @Test + fun `a pasted event is read as its kind, tags and content, and nothing else`() { + val ready = assertIs(GroupEventProposal.read(pastedSchema)) + + assertEquals(31889, ready.kind) + assertEquals("Fields for a bitcoin.mov entry.", ready.content) + assertEquals(11, ready.tags.size) + assertEquals(listOf("d", "bitcoin.mov"), ready.tags.first().toList()) + // The config blob comes through verbatim, escapes and all. + assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", ready.tags[8][6]) + + // The id, author, signature and time are not the paste's to decide, and + // there is nowhere on a reading for them to go: the session hashes the id + // over the room's key and the time it opens at. + val preview = ready.preview() + assertEquals("", preview.pubKey) + assertEquals(0L, preview.createdAt) + } + + @Test + fun `a bare template with no tags or content still reads`() { + val ready = assertIs( + GroupEventProposal.read("""{"kind": 0, "content": "{}"}""") + ) + assertEquals(0, ready.kind) + assertEquals(0, ready.tags.size) + + // A numeric string for the kind is what a hand types; a relay writes a number. + assertIs( + GroupEventProposal.read("""{"kind": "1", "content": "Hello"}""") + ) + } + + @Test + fun `what is not an event is refused and named`() { + assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("not json at all")) + assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("{\"kind\": 1,")) + assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("[1, 2, 3]")) + assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("\"a string\"")) + assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"content": "Hello"}""")) + assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"kind": "one"}""")) + assertEquals( + GroupEventProposal.Refused.TagsMalformed, + GroupEventProposal.read("""{"kind": 1, "tags": "e", "content": "Hello"}""") + ) + assertEquals( + GroupEventProposal.Refused.TagsMalformed, + GroupEventProposal.read("""{"kind": 1, "tags": [["e", 1]], "content": "Hello"}""") + ) + assertEquals( + GroupEventProposal.Refused.ContentNotText, + GroupEventProposal.read("""{"kind": 1, "content": {"note": "Hello"}}""") + ) + } + + @Test + fun `a kind the group's screen has no place for is refused, whatever else it says`() { + // A long-form article is a perfectly good nostr event and the group could + // sign it. It would then be filed and shown nowhere, with nothing to tell + // the member their quorum was spent on it. + assertEquals( + GroupEventProposal.Refused.KindNotAccepted(30023), + GroupEventProposal.read("""{"kind": 30023, "tags": [["d", "x"]], "content": "An article"}""") + ) + + // The kinds with a section: the profile, the posts, the four relay lists + // and the schemas. A subgroup certificate is deliberately not among them. + assertEquals( + setOf(0, 1, 10002, 10050, 10007, 10006, 31889), + GroupEventProposal.ACCEPTED_KINDS + ) + } + + @Test + fun `a kind zero whose content is not a profile is refused, and an empty one is not`() { + assertEquals( + GroupEventProposal.Refused.ProfileUnreadable, + GroupEventProposal.read("""{"kind": 0, "content": "not a profile"}""") + ) + + // Wiping a profile is a thing groups are entitled to do -- the same line + // `GroupNostrProfile.of` draws. + assertIs(GroupEventProposal.read("""{"kind": 0, "content": ""}""")) + assertIs( + GroupEventProposal.read("""{"kind": 0, "content": "{\"name\":\"Translation collective\"}"}""") + ) + } + + @Test + fun `a blank post is refused, the way the composer refuses one`() { + listOf("", " ", "\\n\\n").forEach { blank -> + assertEquals( + GroupEventProposal.Refused.PostBlank, + GroupEventProposal.read("""{"kind": 1, "content": "$blank"}"""), + "a post saying \"$blank\" was accepted" + ) + } + } + + @Test + fun `a schema a reader would refuse is refused here, with the reader's reasons`() { + val withoutVisibility = pastedSchema.replace("""["visibility", "public"],""", "") + + assertEquals( + GroupEventProposal.Refused.SchemaUnusable(listOf(CuratedSchemaProblem.VisibilityMissing)), + GroupEventProposal.read(withoutVisibility) + ) + } + + @Test + fun `a relay list reads whether or not it names a relay`() { + // An empty one is a list the group is withdrawing, which the relays card + // shows as "no relays" rather than as nothing. + assertIs(GroupEventProposal.read("""{"kind": 10002}""")) + assertIs( + GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""") + ) + } + + @Test + fun `a pasted kind zero, once signed, is the group's profile`() { + val ready = assertIs( + GroupEventProposal.read( + """{"kind": 0, "pubkey": "not the group", "content": "{\"name\":\"Translation collective\",\"about\":\"We translate hard books.\"}"}""" + ) + ) + + val profile = assertNotNull(GroupNostrProfile.of(signed(ready), chatRoomId)) + + assertEquals("Translation collective", profile.name()) + assertEquals("We translate hard books.", profile.about()) + assertEquals(chatRoomId, profile.publicKey) + } + + @Test + fun `a pasted kind one, once signed, is one of the group's posts`() { + val ready = assertIs( + GroupEventProposal.read("""{"kind": 1, "tags": [], "content": "We have finished the first chapter."}""") + ) + + val posts = GroupPost.newestFirstAmong(listOf(signed(ready)), chatRoomId) + + assertEquals(listOf("We have finished the first chapter."), posts.map { it.content }) + assertTrue(posts.single().isNewThread()) + } + + @Test + fun `a pasted schema, once signed, is a list the group curates`() { + val ready = assertIs(GroupEventProposal.read(pastedSchema)) + + val curated = assertNotNull(GroupCuratedSchema.of(signed(ready), chatRoomId)) + + assertEquals("bitcoin.mov", curated.identifier) + assertEquals("bitcoin.mov", curated.schema.displayName()) + assertEquals(CuratedVisibility.Public, curated.schema.visibility) + assertEquals(listOf("identifier", "title", "watchUrl"), curated.schema.fields.map { it.name }) + // The coordinate is the group's, not the pasted pubkey's: the group is + // the curator of what it signed. + assertEquals("${CuratedSchemaEvent.KIND}:$chatRoomId:bitcoin.mov", curated.coordinate()) + } + + @Test + fun `a pasted relay list, once signed, is one of the group's relay lists`() { + val ready = assertIs( + GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""") + ) + + val list = GroupRelayList.newestAmong(listOf(signed(ready)), chatRoomId, GroupRelaySet.General) + + assertTrue(list.isAgreed) + assertEquals(listOf("relay.example.com"), list.relays.map { it.displayUrl() }) + assertEquals(listOf(false), list.relays.map { it.read }) + assertEquals(listOf(true), list.relays.map { it.write }) + } + + /** + * A reading as `FrostSigningManager.complete` would file it: authored by the + * room's key at its path, the id hashed over that, a real quorum's signature + * on it, and the time the session opened at rather than anything pasted. + */ + private fun signed(ready: GroupEventProposal.Ready, createdAt: Long = 1_700_000_000): GroupSignedEvent { + val groupPubKey = SharedKeyDerivation + .derive(groupMaterial.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = ready.kind, + tags = ready.tags, + content = ready.content + ) + + return GroupSignedEvent.fromEvent( + event = Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = ready.kind, + tags = ready.tags, + content = ready.content, + sig = groupSignature(groupMaterial, id) + ), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt new file mode 100644 index 00000000..2a699980 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -0,0 +1,105 @@ +package press.mantra.compose.text + +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * How the events a group signs about itself are said to the member signing them. + * + * These are read on the signing screen, in the room's list of proposals and now + * on the paste screen before proposing, and the same words in all three is the + * point of there being one mapping. What is pinned is that each kind is said as + * the thing it is and by the part of it a signer is actually deciding about -- + * a profile by its name, a post by its words, a relay list by its hosts, a + * schema by its list -- and never as "kind 0" over a wall of JSON. + */ +class ProposedEventTest { + + @Test + fun `a profile is said by its name and what it says about itself`() { + val summary = ProposedEvent.summarize( + event(MetadataEvent.KIND, content = """{"name":"Translation collective","about":"We translate hard books."}""") + ) + + assertEquals("Nostr profile", summary.label) + assertEquals("Translation collective · We translate hard books.", summary.detail) + + // Empty and unreadable are different things, and a signer should know which. + assertEquals("An empty profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "{}")).detail) + assertEquals("Unreadable profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "not json")).detail) + } + + @Test + fun `a post is said by its words`() { + val summary = ProposedEvent.summarize(event(TextNoteEvent.KIND, content = "We have finished the first chapter.")) + + assertEquals("Post", summary.label) + assertEquals("We have finished the first chapter.", summary.detail) + } + + @Test + fun `a relay list is said by which list it is and its hosts`() { + val summary = ProposedEvent.summarize( + event( + AdvertisedRelayListEvent.KIND, + tags = arrayOf(arrayOf("r", "wss://relay.one.example"), arrayOf("r", "wss://relay.two.example", "read")) + ) + ) + + assertEquals("General relay list", summary.label) + assertEquals("relay.one.example, relay.two.example", summary.detail) + + // An empty list is a withdrawal, which is worth saying in as many words. + assertEquals("No relays", ProposedEvent.summarize(event(AdvertisedRelayListEvent.KIND)).detail) + } + + @Test + fun `a schema is said by its list, who may suggest, and how much an entry asks for`() { + val summary = ProposedEvent.summarize( + event( + CuratedSchemaEvent.KIND, + tags = arrayOf( + arrayOf("d", "films"), + arrayOf("title", "Film suggestion"), + arrayOf("name", "Films worth translating"), + arrayOf("description", "Films the group would subtitle."), + arrayOf("visibility", "closed"), + arrayOf("field", "title", "text", "required", "", "Title", "{}"), + arrayOf("field", "year", "year", "optional", "", "Year", "{}"), + ), + content = "Films the group would subtitle." + ) + ) + + assertEquals("Curated schema", summary.label) + assertEquals("Films worth translating · closed · 2 fields", summary.detail) + } + + @Test + fun `a kind with no words of its own keeps its number`() { + // Refusing to describe an event is better than describing it wrongly. + val summary = ProposedEvent.summarize(event(30023, content = "An article")) + + assertEquals("Event of kind 30023", summary.label) + assertEquals("An article", summary.detail) + } + + private fun event( + kind: Int, + tags: Array> = emptyArray(), + content: String = "", + ) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000, + kind = kind, + tags = tags, + content = content, + sig = "c".repeat(128), + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index ce40ccec..dead059f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -252,6 +252,29 @@ class GroupNostrProfileSectionJvmTest { onNodeWithContentDescription("Edit schema").assertDoesNotExist() } + @Test + fun `a member holding a share of the key may propose any event the block can show`() = render( + state(groupNostrProfile = profile, relayLists = signedRelayLists()) + ) { + // Last in the identity block: after the schemas and before the divider, + // because it is about the whole block rather than any one section of it. + val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top + val propose = onNodeWithText("Propose event").getBoundsInRoot().top + val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top + + assertTrue(schemas < propose, "the paste entry climbed above the schemas") + assertTrue(propose < subgroups, "the paste entry fell out of the identity block") + } + + @Test + fun `a member holding no share of the key is offered no paste`() = render( + state(groupNostrProfile = profile, canEditNostrProfile = false) + ) { + // The same gate as every button in the block: proposing is a signature by + // the group, and `proposeSigning` throws for a device holding no share. + onNodeWithText("Propose event").assertDoesNotExist() + } + @Test fun `a group that has said nothing says so, and offers to say something`() = render( state(groupNostrProfile = null) @@ -289,6 +312,7 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Add post").assertDoesNotExist() onNodeWithText("Curated schemas").assertDoesNotExist() onNodeWithText("Add schema").assertDoesNotExist() + onNodeWithText("Propose event").assertDoesNotExist() // The rest of the screen is untouched, so the section's absence is an // absence rather than a screen that failed to draw. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt new file mode 100644 index 00000000..6b471f77 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt @@ -0,0 +1,116 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ProposeGroupEventUIState +import kotlin.test.Test + +/** + * What the paste screen says back as an event is typed into it. + * + * The screen's one job beyond a text field is to tell a member, before a quorum + * is asked, what the group would be signing or why it will not be asked. Both + * are read live off the paste, so both are tested by typing: a schema reads back + * as the signing screen would describe it, junk is named as junk, and a kind + * with no section to land in is refused with the kinds that have one. + */ +@OptIn(ExperimentalTestApi::class) +class ProposeGroupEventScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val schema = """{"kind":31889,"created_at":1735689600,"tags":[["d","bitcoin.mov"],""" + + """["title","bitcoin.mov suggestion"],["name","bitcoin.mov"],""" + + """["description","Fields for a bitcoin.mov entry."],["visibility","public"],""" + + """["field","title","text","required","","Title","{}"]],"content":"Fields for a bitcoin.mov entry."}""" + + @Test + fun `a pasted schema is read back as the signing screen would say it`() = render(state()) { + onNodeWithText("Unsigned event").performTextInput(schema) + + onNodeWithText("What the group would sign").assertIsDisplayed() + onNodeWithText("Kind 31889 · Curated schema").assertIsDisplayed() + onNodeWithText("bitcoin.mov · public · 1 field").assertIsDisplayed() + } + + @Test + fun `junk is named as junk`() = + render(state()) { + onNodeWithText("Unsigned event").performTextInput("this is not an event") + onNodeWithText("That isn't JSON.").assertIsDisplayed() + onNodeWithText("What the group would sign").assertDoesNotExist() + } + + @Test + fun `a kind the screen cannot file is refused with the kinds it can`() = render(state()) { + onNodeWithText("Unsigned event").performTextInput("""{"kind": 30023, "content": "An article"}""") + + onNodeWithText("Kind 30023 has nowhere to go on the group's screen.", substring = true) + .assertIsDisplayed() + onNodeWithText("0, 1, 10002, 10006, 10007, 10050, 31889", substring = true).assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key is told so and the button is inert`() = render( + state(canSign = false) + ) { + onNodeWithText("This group has no shared key, so it cannot sign an event.", substring = true) + .assertIsDisplayed() + onNodeWithContentDescription("Propose event").assertIsNotEnabled() + } + + private fun state(canSign: Boolean = true) = ProposeGroupEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + canSign = canSign, + ) + + private fun render( + uiState: ProposeGroupEventUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 1200) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + ProposeGroupEventScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialProposeGroupEventUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } + } + } + + assertions() + } +} From ce951bb5d0290cc69e9bd32b43b08107abac358c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:00:07 +0200 Subject: [PATCH 12/71] docs(scripts): the pin follows the commit even when git fast-forwards the gitlink Found landing Phase 3 for real, one commit after the trial had passed it. In the trial worktree the submodule directory was an empty placeholder, so when 366b0177 moved the pin from 01489b8 to 59c11ed against a branch already at 84cc44c, git could not compare the three and reported a conflict, and the pin rule set 59c11ed as intended. On the real branch the submodule is populated, git can see that 59c11ed is an ancestor of 84cc44c, and it resolves the gitlink by fast-forward -- cleanly, silently, and to the newer pin, which is the one the commit does not compile against. So the pin rule no longer waits for a conflict: after any pick of a commit the table names, the gitlink is set to what the table says, and the note says git had fast-forwarded it. The same code path now covers 5efeae76's mapping of the unfetchable 01962f3 onto 84cc44c, which used to be a special case. Phase 3 was reset to the Phase 2 tip and is landed again with this in place; nothing else about those eight commits changes. Co-Authored-By: Claude Opus 5 --- docs/scripts/curated-replay.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/scripts/curated-replay.py b/docs/scripts/curated-replay.py index f0ac19dd..945cd542 100755 --- a/docs/scripts/curated-replay.py +++ b/docs/scripts/curated-replay.py @@ -134,9 +134,12 @@ def main(): for path, src in AFTER.get(o8, []): open(os.path.join(wt, path), 'w').write(git('show', f'{rw(src)}:{path}').stdout); git('add', path) notes.append(f'{os.path.basename(path)}: brought to the state the original merge {src} left it in, an edit that merge made outside its conflicts') - if o8 == '5efeae76': + if o8 in PIN and git('ls-tree', 'HEAD', GITLINK).stdout.split()[2] != PIN[o8]: + # a populated submodule lets git resolve the gitlink by fast-forward and keep the newer pin; + # the pin has to follow the commit, since each compiles only against the library it named git('update-index', '--cacheinfo', f"160000,{PIN[o8]},{GITLINK}") - notes.append('gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library\'s master as 84cc44c with an identical tree') + notes.append('gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library\'s master as 84cc44c with an identical tree' if o8 == '5efeae76' + else f'gitlink -> {PIN[o8][:8]}, the pin this commit compiles against (git had fast-forwarded it to the newer one)') if notes: git('commit', '-q', '--amend', '--no-edit', check=False) amend_note('Replayed onto Mantra by docs/curated-to-mantra.md: ' + '; '.join(notes) + '.') From c49914818a33e17493d656ef31b73765f0b01775 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 11:28:15 +0200 Subject: [PATCH 13/71] docs: plan nsec sign-in, and the ten call sites that make it small An nsec is a BIP32 leaf of the seed at m/44'/1237'/0'/0/0, and the derivation runs one way, so an nsec can never have a wallet behind it. What makes the feature tractable anyway is that nothing in the app reads the wallet except the nostr key -- ten sites, all the same expression -- so the plan puts an Identity in front of the wallet and gives an nsec an identity with no node behind it. Eight phases: the identity type; a sibling key file in the library under the one keystore alias Android will accept; listing and starting both kinds; the sign-in screen for a phrase or an nsec, deduped on pubkey rather than wallet id; indexer relays and a not-found exit for the sync that today asks one relay and never finishes; recovery for a key with no phrase; tests; rollout. Two findings along the way are recorded as pre-existing rather than new: loadNostrProfile(startupRoute) keys off a Profile row a placeholder account does not have and answers Landing while observeProfile answers the sync screen, and the library's LocalKeyManager.nostrPublicKey() returns the 33-byte compressed key, not a nostr key. Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@daae63d5c5356cb53aff911d7c66acbb2a0a8647 --- docs/README.md | 5 + docs/nsec-sign-in.md | 992 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 997 insertions(+) create mode 100644 docs/nsec-sign-in.md diff --git a/docs/README.md b/docs/README.md index 70f1efd3..c5857a54 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ silent, or a decision that looked arbitrary and was not. | [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | +| [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable | | [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 | | [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 | @@ -39,3 +40,7 @@ jvm-target note first if you want to know why its adaptive-layout phase exists. The curated-to-mantra note is a phased plan that has not been built, though its dry run has: it is about the repository rather than the app, and reads alone, except that its first decision leans on the derivation note's one rule. +The nsec sign-in note is a phased plan that has not been built; it inherits the +key-storage decision from the jvm-target note and drives the navigation state +machine `NavigationViewModel.processLocalAccount` implements, so read it with the +code open. diff --git a/docs/nsec-sign-in.md b/docs/nsec-sign-in.md new file mode 100644 index 00000000..c9aad8b8 --- /dev/null +++ b/docs/nsec-sign-in.md @@ -0,0 +1,992 @@ +# Signing in with an nsec + +How a user who already has a nostr key gets it onto this device, why that key can +never have a wallet behind it, and the ten call sites that make the whole thing +smaller than it looks. + +Read this against [jvm-target.md](./jvm-target.md) for the key-storage decision it +inherits, and with `NavigationViewModel.processLocalAccount` open for the state +machine it drives — the machine is already built, and half of this plan is about +finding its unreachable entrance. + +**Not built.** Phases 1–8 below, one commit each, in the order given. Phases 1 and +2 are independent and can go in parallel; nothing after Phase 3 can start before +it, because Phase 3 is where the compiler first checks both halves against each +other. + +## The constraint + +The profile's nostr key is not stored anywhere. It is derived, every time the +wallet starts, at the NIP-06 path: + +```kotlin +// lightning-kmp-app/library/.../managers/WalletManager.kt:105 +fun LocalKeyManager.nostrPrivateKey(): PrivateKey { + val path = KeyPath(if (isMainnet()) "m/44'/1237'/0'/0/0" else "m/44'/1237'/1'/0/0") + return derivePrivateKey(path).privateKey +} +``` + +That is BIP32 child derivation — `HMAC-SHA512(chainCode, parentKey ‖ index)` at +every step — and it runs one way. Holding the leaf tells you nothing about the +seed above it, and no seed can be chosen to land on a given leaf. So the object the +whole app hangs off, `LocalKeyManager`, is out of reach for an nsec by +construction: + +```kotlin +// lightning-kmp/modules/core/.../crypto/LocalKeyManager.kt:34 +data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwapInExtendedPublicKey: String) +``` + +No `LocalKeyManager` means no node key, no `PhoenixBusiness`, no channels, no +on-chain wallet. **"Restore only the nostr key" is not a product decision** — it +is the only thing an nsec can be restored *as*. Every design below starts from +that, and the ones in the [appendix](#appendix--what-was-considered-and-rejected) +that tried to get around it are there because they cannot. + +One consequence worth stating early: the twelve words and the nsec are not two +encodings of one thing, so a user who signs in with an nsec and later wants a +wallet is creating a *second* secret with a *second* recovery story. That is out +of scope here and is said so at the end. + +## What the app actually needs from the wallet + +Nothing but the key. That is the finding that makes this tractable. + +Every consumer of the wallet in `composeApp` is the same expression: + +```kotlin +activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() +``` + +ten times, in eight files: + +| site | what it does with the key | +|---|---| +| [NotaryViewModel.kt:71](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt) | signs the queues, seals Marmot bundles with `nsecPassword` | +| [SynchronizationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt) | the three pumps' key pair | +| [RelaysSocketManager.kt:68](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) | the pubkey whose relay list to follow | +| [NavigationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | the pubkey whose account to observe | +| [DkgRitualViewModel.kt:476](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt), 516, 570 | the ChillDKG host key, derived from the nostr key | +| [SelectChatRoomTypeViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt) | same | +| [SelectSubgroupAdminsViewModel.kt:208](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt) | same | +| [NostrNotaryRepository.kt:70](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt) | signs — but nothing constructs this class; it only has to compile | + +And that is the whole list. There is no call into `peerManager`, `paymentsManager`, +`balanceManager`, `sendManager` or `lnurlManager` anywhere in the app. The +Lightning node — Electrum connection, peer connection to the LSP, the WorkManager +watchers `schedulePlatformLogic` sets up — is started on every cold boot as a +**side effect of reaching a 32-byte key**. + +Everything downstream of the key is already independent of the seed. +`ChillDkgRitualManager.deriveHostSecretKey` hashes the nostr key +([ChillDkgRitualManager.kt:117](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt)); +`nsecPassword` is `hash160` of it; the notary and pumps build a quartz `KeyPair` +from its bytes. None of them would notice where the key came from. + +So the leverage point is one type: put a *signing identity* between the app and +the wallet, and an nsec becomes "an identity with no wallet behind it". The ten +sites collapse to one flow, and two of them get simpler — the `flatMapLatest` over +the key manager's `StateFlow` in the notary and the relay manager exists only +because the key arrives late, after the node; an identity is whole from the moment +it exists. + +## What is already built + +More than you would expect. The nostr half of "restore" was designed for the +app's earlier life as Torch and left in place, unreachable, when the seed became +the only way in. + +| piece | where | state | +|---|---|---| +| `signInToProfile(pubkey)` — plants a kind‑0 `UnsignedNostrEvent` stamped `signedAt = GENESIS_AT`, so the notary skips it and the navigation machine treats the account as "signed, needs syncing" | [DatabaseNostrRepository.kt:215](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | built, no caller | +| the state machine that takes it from there: `UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded` | [NavigationViewModel.kt:96](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built | +| the `purpose = "sign-in"` relay sync for kinds 0, 3, 10002, 10005, 10007, 10012, 10050 and 10086 — profile, contacts, and every relay list the app reads | [UnqueuedProfileSynchronizationViewModel.kt:57](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt) | built, asks one relay — see [Phase 5](#phase-5--finding-the-profile-and-not-finding-it) | +| a `SignInToProfileViewModel` that parses `nsec1…`/`npub1…`/`nostr:` with quartz's `Nip19Parser` and calls `signInToProfile`; a `SignInToProfileUIState` with `InputPrompt`, `ConfirmNsecSignIn(nsec, hexKey)`, `ConfirmNpubSignIn`, `Error`; a `SignInToProfileFormState` | [SignInToProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt) | built, unreferenced — and it **drops the nsec on the floor**; nothing ever stored it | +| `String.extractKeyPairFromPrivateKeyOrThrow()` — hex or bech32 in, `(nsec, npub)` out, `InvalidNostrPrivateKeyException` otherwise | [Credentials.kt:60](../composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Credentials.kt) | built | +| strings: `enter_the_nsec_or_npub_read_only_that_you`, `sign_in_to_nsec`, `sign_in_with_an_npub`, `be_sure_to_keep_this_nsec_safe`, `sign_in_to_torch_via_nsec_or_remote_signer` | `strings.xml` | present, unused | +| `ActiveWallet.business` is already `PhoenixBusiness?` | [Wallet.kt:50](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt) | nullable today | +| `keyStoreEncryption` / `keyStoreDecryption` — public `expect` functions with android, jvm and ios actuals, parameterised by key alias | [KeyStoreFunctions.kt](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt) | usable as-is | +| `platformWriteSeed(…, isRestoringWallet = …)` — the seed writer already has a restore flag | [NavigationViewModel.android.kt:64](../composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt) | built; recovery-phrase sign-in is nearly free once a screen exists | +| `SignInToProfileScreen` | [SignInScreen.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt) | a sentence saying sign in is not available | + +The gap, then, is exactly two things: **somewhere for the nsec to live**, and +**something for the app to read the key from that is not the node**. Everything +else is wiring. + +## What actually blocks it + +Four things, in dependency order. + +**The at-rest store is shaped for words.** `seed.dat` is +`EncryptedSeed.V2.MultipleSeed`: the platform keystore's AES over a JSON +`Map>` +([EncryptedSeed.kt:60](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt)), +and `SeedManager.loadAndDecrypt` runs `MnemonicCode.toSeed` on every entry and +builds a `LocalKeyManager` from it to learn the wallet id +([SeedManager.kt:48](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt)). +A 32-byte key has no place in that shape, and the [appendix](#extending-encryptedseed-with-a-version-4-payload) +says why it should not be given one. + +**The identity is carried by the node.** `activeWalletInUI: +StateFlow` is set only from `StartBusinessResult.Success` +([SovereignWalletViewModel.kt:118](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)), +and twenty-five parameters across eighteen files are typed on it. + +**Startup only knows about wallets.** `SovereignWalletStartupScreen` sends an +empty `availableWallets` to the landing page +([SovereignWalletStartupScreen.kt:91](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)) +and starts whatever is selected with `startupNode(words)` (line 171). An nsec +stored anywhere else is invisible to it, and `NavigationViewModel.observeProfile` +answers a null `business` by navigating back to startup +([NavigationViewModel.kt:187](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt)) +— a loop, for an identity that will never have one. + +**The bootstrap asks the wrong relay.** The sign-in sync queues its `REQ` at +`Relays.DefaultDMRelayList`, which is `listOf(ephemeral)` — +`wss://ephemeral.mantra.press` +([Relays.kt:61](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt)). +That is the right relay for a profile *this app* created. An identity that has +lived on Damus for three years has never heard of it, and the machine has no exit +for "nothing came back". + +## The one decision to make first + +Should a mnemonic identity still start the Lightning node to reach its key? + +`CreateProfileViewModel` already derives the pubkey without one — it constructs +`LocalKeyManager(seed)` locally and reads `nostrPublicKey()` off it +([CreateProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt)). +Doing the same at startup would make both kinds of identity symmetric — secret → +key in memory, node optional — and would take the Electrum and LSP connections off +every cold boot of what is, today, a chat application. + +**This plan keeps the node start for mnemonic identities.** Three reasons. +`BusinessManager.startNewBusiness` does more than load a key — it registers wallet +metadata, applies Electrum and Tor preferences, records the last-used app code, +and the comments in `SovereignWalletStartupScreen` and `SovereignWalletViewModel` +are the record of a flow that has already bitten twice; moving it is its own piece +of work with its own review. The lazy-start path will have to exist anyway the +day a wallet feature ships. And Phase 1 makes the later change small: once +everything reads an `Identity`, whether the node is behind it is one branch in one +place. + +So: the nsec branch has no node, the mnemonic branch keeps the one it has, and +"start the node lazily" is a follow-on named under [Out of scope](#out-of-scope). + +--- + +## Phase 1 — an identity in front of the wallet + +**App only. No behaviour change.** This is the refactor everything else stands on, +and it should land alone so that its diff is boring. + +### The type + +```kotlin +package press.mantra.compose.identity + +enum class IdentityKind { + /** Twelve words. The nostr key is derived from them, and so is a wallet. */ + Mnemonic, + /** A bare nostr secret. Nothing else can be derived from it. */ + NostrSecret, +} + +data class Identity( + val id: WalletId, + val kind: IdentityKind, + val nostrPrivateKey: PrivateKey, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + /** The running node. Non-null only for [IdentityKind.Mnemonic]. */ + val business: PhoenixBusiness?, +) { + /** X-only, hex — the form every nostr call site wants. Computed once. */ + val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex() + + override fun toString() = "Identity(id=$id, kind=$kind, key=)" +} +``` + +It replaces `ActiveWallet` in the app rather than wrapping it. `ActiveWallet` is +declared in the library but nothing in the library uses it — it is an app type +that happens to live in the wrong module — and flattening its three fields into +`Identity` means `KeyRecoveryScreen` and `RecoveryPhraseViewModel`, which read +`internalPrefs` off the active wallet today, keep working for an identity that +has no wallet. + +**`WalletId` stays as the id.** The field is called `nodeIdHash` and for an nsec +identity it will not be one, but every preference in the library keys on the type +— `loadUserPrefsForWallet`, `loadInternalPrefsForWallet`, `UserWalletMetadata`, +`getDefaultWallet` — and a second id type would mean a second copy of each. For +an nsec identity: + +```kotlin +// app side; WalletId has no companion to hang this off +fun XonlyPublicKey.toWalletId(): WalletId = + WalletId(Crypto.hash160(value).byteVector().toHex()) +``` + +Same shape as `WalletId(nodeId: PublicKey)` — forty hex characters of a +`hash160` — so nothing downstream can tell the kinds apart by the id, which is +what you want from an id. The kind is on the `Identity`, where it can be asked. +The two hashes are over different keys (the node key at `m/50'/0'`, the nostr +key at `m/44'/1237'/…`), so a mnemonic wallet and its own nostr identity would +never share an id even if both were registered — which is exactly the case +Phase 4's dedupe has to catch by pubkey instead. + +### The flow + +`SovereignWalletViewModel` gains + +```kotlin +private val _activeIdentity = MutableStateFlow(null) +val activeIdentity = _activeIdentity.asStateFlow() +``` + +and `setActiveWallet(walletId, business)` becomes `setActiveIdentity(identity)`, +with the mnemonic caller building the identity from the node it just started: + +```kotlin +val keyManager = business.walletManager.keyManager.value + ?: error("business started without a key manager") +Identity( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyManager.nostrPrivateKey(), + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId), + business = business, +) +``` + +`activeWalletInUI` goes. The twenty-five `StateFlow` parameters +become `StateFlow` — a rename in most of them; the eighteen files are +listed by `grep -rl 'StateFlow' composeApp/src/commonMain`. + +### The ten sites + +Each becomes a read of `activeIdentity.value?.nostrPrivateKey` (or +`?.nostrPublicKey`). Two deserve a word: + +- **NotaryViewModel** and **RelaysSocketManager** currently `flatMapLatest` from + the wallet flow into the key manager flow, because the key arrives after the + node. With an identity there is one flow and one `collectLatest`, and the + children it launches are cancelled on identity change exactly as they are now. + Keep the `distinctUntilChanged` on the key: a `data class` `Identity` compares + by value and the prefs objects inside it are stable per id, so it already + behaves, but the comment on `observeUnsignedNostrEvents` is about a + `distinctUntilChanged` that bit once; do not remove one without reading it. +- **NavigationViewModel.observeProfile** loses its `business == null → + StartupPhoenix` branch. That branch was the only thing that made a nodeless + identity loop; a null *identity* still means "go start something", which is + right. + +`WalletManagerExtension.kt` — the app's own `LocalKeyManager.nostrPublicKey()` — +is used by exactly these sites and can go with them. + +### Tests + +`NavigationRoutingTest` constructs a `NavigationViewModel` around a +`MutableStateFlow`; it becomes `MutableStateFlow` and +gains one case: an identity with `business = null` and a local account routes to +`ProfileLoaded`, not `StartupPhoenix`. That is the regression this phase is for, +and it is the assertion Phase 3 will lean on. + +--- + +## Phase 2 — the nostr key store + +**Library. Independent of Phase 1.** + +A sibling of the seed file, not a change to it: `node-data/nostr-keys.dat`, +encrypted under the same keystore key, with the same write discipline, read by a +manager shaped like `SeedManager`. + +### Why the same alias + +`KeyStoreNames.KEY_NO_AUTH`. Not for convenience — because Android gives no +choice: + +```kotlin +// KeystoreHelper.kt:74 +private fun getKeyForName(keyName: String): SecretKey = when (keyName) { + KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired() + KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired() + else -> throw IllegalArgumentException("unhandled key=$keyName") +} +``` + +A new alias is a library change on that platform regardless, and both existing +aliases already resolve to the one key. On the jvm, `JvmKeyStore.unlock` runs in +`Main.kt` before anything reads +([Main.kt:146](../composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt)), so a +second file under the same alias costs nothing there either; the ios keychain +helper is likewise keyed by alias. + +### The format + +``` +byte 0 file version = 1 +bytes 1..16 iv +bytes 17.. ciphertext of UTF-8 JSON: { "": "", … } +``` + +One version byte, not two. `EncryptedSeed.V2` carries a second because it has to +distinguish `SingleSeed` from `MultipleSeed`; this file has one shape and a new +shape would be a new version. Keyed by the **public** key so that a lookup and a +duplicate check are the same map operation, and so that the store can list what +it holds without decrypting anything more than it already has. + +```kotlin +package fr.acinq.phoenix.security + +class EncryptedNostrKeys(val iv: ByteArray, val ciphertext: ByteArray) { + fun decryptAndGetKeyMap(): Map + fun serialize(): ByteArray + companion object { + const val VERSION: Byte = 1 + fun deserialize(bytes: ByteArray): EncryptedNostrKeys + fun encrypt(keys: Map): EncryptedNostrKeys // KEY_NO_AUTH + } +} +``` + +```kotlin +package fr.acinq.phoenix.managers + +object NostrKeyManager { + fun loadAndDecrypt(phoenixGlobal: PhoenixGlobal): DecryptNostrKeysResult + fun loadAndDecryptOrNull(phoenixGlobal: PhoenixGlobal): Map? // empty map when no file + fun writeToDisk(phoenixGlobal: PhoenixGlobal, keys: EncryptedNostrKeys) +} +``` + +`writeToDisk` is `SeedManager.writeSeedToDir` with the type changed: encrypt to +`temporary_nostr_keys.dat`, read it back, compare `iv` and `ciphertext` +byte-for-byte, `atomicMove` over `nostr-keys.dat`, delete the temp file on any +failure. That discipline exists because a truncated seed file is an unrecoverable +wallet; a truncated key file is an unrecoverable identity, and the reasoning +transfers whole. Lift the body into a private helper both managers call rather +than copying it — the diff will show whether that is one function or two. + +`DecryptNostrKeysResult` mirrors `DecryptSeedResult` — `Success(map)`, +`Failure.FileNotFound`, `SerializationError`, `KeyStoreFailure(cause)`, +`DecryptionError(cause)`, `FileUnreadable` — and the exception mapping is the +one `gracefulMultiSeedDecryption` does per platform +([TechnicalExtensions.android.kt:17](../lightning-kmp-app/library/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt)): +`SerializationException`/`IllegalArgumentException` → serialization, +`KeyStoreException` → keystore, else → decryption. Those three `inline` actuals +are typed on `DecryptSeedResult`; rather than three more, pull the classification +into a common `expect fun classifyDecryptionFailure(e: Exception): DecryptionFailureKind` +and have both result types map from it. + +### Two small things while in this file + +**`LocalKeyManager.nostrPublicKey()` in the library is not a nostr public key.** +It returns `nostrPrivateKey().publicKey().toHex()` — the 33-byte compressed +encoding, sixty-six hex characters. Nostr keys are x-only, sixty-four. Nothing in +the app calls it (the app has its own, correct, `press.mantra.compose.extensions.nostrPublicKey`), +which is the only reason it has not mattered. Fix it or delete it; a helper this +plan does want is + +```kotlin +fun PrivateKey.nostrPublicKeyHex(): String = publicKey().xOnly().value.toHex() +``` + +so that the app's `Identity.nostrPublicKey` and the store's map key are computed +the same way in one place. + +**Testnet derives at account `1'`.** `m/44'/1237'/1'/0/0` is legal — NIP-06 leaves +the account index to the application — but an imported nsec bypasses the chain +entirely, so a testnet build signs with whatever key was pasted. That is correct; +it is only worth a comment next to the path so nobody "fixes" it. + +### Tests + +Two, in the library, each following a test that already exists. + +`EncryptedNostrKeysTest` in `commonTest`, after `EncryptedSeedTest`: the +serialized layout spelled out as literal bytes — version byte, sixteen iv bytes, +the rest — because, as that test's header says, the format is a compatibility +contract and the expected bytes must not be derived from the constants under test. + +A round trip in `jvmTest`, after `JvmKeyStoreTest`: `JvmKeyStore.unlock` against +a temp directory, `NostrKeyManager.writeToDisk`, `loadAndDecrypt`, and the same +map back. This is the one platform where encrypt-and-decrypt can be exercised on +the host, and it covers the atomic write path, which the layout test cannot. + +--- + +## Phase 3 — listing and starting identities + +**App. Needs Phases 1 and 2.** + +### Listing + +`SovereignWalletViewModel.listAvailableWallets` reads one store and exposes +`availableWallets: Map`. It becomes `listIdentities`, +reads both, and exposes + +```kotlin +sealed interface StoredIdentity { + val id: WalletId + val nostrPublicKey: HexKey + data class Mnemonic(val userWallet: UserWallet, override val nostrPublicKey: HexKey) : StoredIdentity { + override val id get() = userWallet.walletId + } + data class NostrSecret(override val id: WalletId, override val nostrPublicKey: HexKey, val privateKey: PrivateKey) : StoredIdentity +} +val availableIdentities: StateFlow> +``` + +For a mnemonic entry the nostr pubkey is one `LocalKeyManager(MnemonicCode.toSeed(words))` +away; `SeedManager.loadAndDecrypt` has already built exactly that key manager to +learn the node id, so this is the second derivation, not a new cost. The pubkey +is needed at listing time for one reason — see [dedupe](#dedupe-on-the-nostr-key-not-the-id) +in Phase 4 — and it is what the selector should show for an nsec identity, where +there is no node id to show. + +On secrets in memory: `availableWallets` holds decrypted words today for the +lifetime of the view model, because `startupNode(words)` needs them. +`StoredIdentity.NostrSecret` holds the private key for the same reason and is no +worse. Tightening that — decrypt at activation, hold only ids and pubkeys in the +list — is a real improvement and applies to both kinds equally, which is why it +is not done here. + +Metadata registration is unchanged: the loop that saves a `UserWalletMetadata` +with a random avatar for every id it has not seen +([SovereignWalletViewModel.kt:128](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)) +works on `WalletId` and does not care what is behind it. + +### Starting + +`SovereignWalletStartupScreen` picks a `StoredIdentity` where it now picks a +`UserWallet`, and `LoadWallet` — the screen-lock gate — takes the identity rather +than the wallet. It only ever read `walletId` off the wallet, to look up the lock +preferences, so the change is the parameter type; and it matters that the gate +sits *outside* the branch, so a future lock is not something to remember to add +twice: + +```kotlin +LoadWallet(stored, metadata, userPrefs, promptScreenLockImmediately) { stored -> + when (stored) { + is StoredIdentity.Mnemonic -> + sovereignWalletStartupViewModel.startupNode(stored.id, stored.userWallet.words) { business -> + sovereignWalletViewModel.setActiveIdentity(identityFor(stored.id, business)) + onSuccessfulStartup() + } + is StoredIdentity.NostrSecret -> { + sovereignWalletViewModel.setActiveIdentity( + Identity( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + ) + onSuccessfulStartup() + } + } + loadingIdentity = null +} +``` + +No `platformStartupLogic`, no `schedulePlatformLogic`, no `StartupViewState` +transitions for the nsec branch — an nsec identity is active the moment it is +read. The `StartingBusiness`/`BusinessActive` spinners that follow the `when` in +the screen today are reached only from the mnemonic branch, which is the one that +still has something to wait for. + +`availableWallets.isEmpty()` (line 91) becomes `availableIdentities.isEmpty()`. +The default-wallet and desired-wallet logic keys on `WalletId` and needs nothing. + +### The other entrance, and the race it hides + +`NavigationViewModel` decides where a boot goes from two places, and only one of +them is the flow the ten sites feed. `onSuccessfulStartup` calls +`loadNostrProfile(startupRoute)`, which reads + +```kotlin +val activeUserPublicKey = nostrRepository.getLocalAccounts().firstOrNull()?.profile?.publicKey +if (activeUserPublicKey == null) { … Landing … } +``` + +Two things are wrong with that line for this plan. It takes the *first* kind-0 +account on the device, whichever identity is active — harmless with one wallet, +wrong the moment there are two. And it keys off `profile.publicKey`, which is the +`Profile` row the notary upserts when it *signs* the kind 0. A placeholder +account planted by `signInToProfile` has an `UnsignedNostrEvent` and no +`Profile`, so this reads null and answers **`Landing`** — for a key that has just +been imported. Meanwhile `observeProfile`, collecting the same account through +the identity flow, answers `UnqueuedProfileSynchronization`. Both write +`_navigationUIState`; the order is whichever coroutine runs last, and `Landing` +navigates with `popUpTo(0)`. + +The create flow has the same window today — between `createNewProfile` and the +notary signing the kind 0, there is no `Profile` either — and gets away with it +because the flow collector keeps emitting as the rows change. An imported +identity's rows change less often. So, in this phase: + +```kotlin +val identity = activeIdentity.value +val account = nostrRepository.getLocalAccounts() + .firstOrNull { it.unsignedNostrEvent?.pubKey == identity?.nostrPublicKey } +val activeUserPublicKey = account?.unsignedNostrEvent?.pubKey +``` + +Read by the active identity, keyed on the unsigned event's `pubKey` — the row +`signInToProfile` writes and `getLocalAccounts()` selects on — and pass the +account straight to `processLocalAccount`, which already knows what a placeholder +means. `NavigationRoutingTest` gains the fixture it is missing: an account whose +`profile` is null and whose `signedAt` is `GENESIS_AT`, asserted to land on +`UnqueuedProfileSynchronization` and never on `Landing`. + +### The selector + +The app's `WalletsSelector` takes `Map` and shows +`userWallet.nodeId` in monospace +([WalletsSelector.kt:55](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt)). +It takes `Map` and shows the npub for an nsec identity +and the node id for a mnemonic one — or the npub for both, which is the identifier +the rest of the app uses and the one a user might actually recognise. The +`onWalletClick` callback carries the `StoredIdentity`. + +### Hoist the seed writer while you are here + +The three `platformWriteSeed` actuals — android, jvm, ios — are byte-identical +(`diff` of the three bodies is empty). Every symbol they use is `commonMain`: +`SeedManager`, `EncryptedSeed`, `LocalKeyManager`, `DataStoreManager`, +`AppVersion`. They are an `expect` because something once needed to differ and +nothing does now. Before adding a second writer beside them, make them one +common function; then add + +```kotlin +suspend fun writeNostrKey(phoenixGlobal, privateKey: PrivateKey, isTorEnabled, customElectrumServer): WalletId +``` + +next to it, with the same shape: load the existing map, refuse a duplicate, add, +encrypt, write, save the per-id prefs, return the id. The Tor and Electrum prefs +are meaningless for an nsec identity; save them anyway, because +`loadUserPrefsForWallet` is what creates the prefs file and the recovery screens +read it. + +### Tests + +`NavigationRoutingTest` already covers the state machine from a local account +inward. Add, in `jvmTest`, a `SovereignWalletViewModel` listing test that seeds +both stores through the jvm keystore (Phase 2's fixture) and asserts the merged +map: one entry per secret, ids of the right shape, pubkeys that match what +`Identity` computes. This is the test that would have caught the two stores +disagreeing about what an id is. + +--- + +## Phase 4 — the sign-in screen + +**App. Needs Phase 3.** `SignInToProfileScreen` becomes a screen. + +### Shape + +One screen, one text field, two things it accepts — because a user does not +choose an input type, they paste what they have, and the two are unambiguous: + +| pasted | recognised as | validation | +|---|---|---| +| twelve (or twenty-four) space-separated words | recovery phrase | `MnemonicCode.validate(words, English.wordlist())` — the checksum catches a wrong word | +| `nsec1…`, or `nostr:nsec1…` | nostr secret | quartz `Nip19Parser.uriToRoute(...)?.entity is NSec`, as the skeleton already does | +| 64 hex characters | nostr secret | `extractKeyPairFromPrivateKeyOrThrow()` accepts it; the confirm step shows the npub so a wrong paste is visible | +| `npub1…` | rejected, with the reason | see [Out of scope](#out-of-scope) — the app cannot do anything with a key it cannot sign with | +| `ncryptsec1…` | rejected, with the reason | NIP-49; a cheap follow-on, named in Out of scope | + +The field is a `TextFieldForm` with `SignInToProfileFormState`, which exists. +Trim, collapse whitespace, lower-case a hex string; do not lower-case words +(the wordlist is lower-case already and a capitalised word is a paste artefact +worth showing rather than silently fixing). + +### Flow + +`SignInToProfileUIState` already has the states; use them, with `ConfirmNsecSignIn` +generalised to a `Confirm(kind, npub, hexKey)`: + +1. **InputPrompt.** The field, a paste button, and the sentence from + `enter_the_nsec_or_npub_read_only_that_you` rewritten for what is actually + accepted — a recovery phrase or an nsec. Sentence case. New string. +2. **Confirm.** The derived npub, in full, in monospace, with which kind was + recognised, and one button. This is the step that catches the paste of the + wrong nsec, and for a recovery phrase it is where the user learns which + profile the words open. The nsec itself is never echoed back. +3. **Committing.** `LoadingDataIndicator`. What happens is in the next section. +4. **Error**, through `ErrorState` with an `onRetry` that returns to the prompt; + `null` is not the right answer here. Failures are named: invalid checksum, + not a key, already on this device, could not write. + +Four states, said so, wrapped in `ScreenStateTransition` since the `when` is the +body. Snackbar for the one success message, read above the handler through +`rememberNotifier`. Content root has `readableContent()`; the field spans it. + +### Committing an nsec + +```kotlin +val privateKey = PrivateKey.fromHex(entity.hex) // NSec.hex, 32 bytes, from the parser +val pubkey = privateKey.nostrPublicKeyHex() +rejectIfKnown(pubkey) // below +val id = writeNostrKey(phoenixGlobal, privateKey, …) // Phase 3's writer +nostrRepository.signInToProfile(publicKey = pubkey) // the kind-0 placeholder, GENESIS_AT +sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToWallet(id) + navController.navigate(SovereignWalletStartupRoute) +} +``` + +The tail is the one `CreateProfileRoute` already uses after `writeSeed` +([MantraNavHost.kt:505](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)): +re-list, select, go to startup. Startup finds a `StoredIdentity.NostrSecret`, +activates it, `NavigationViewModel` observes its pubkey, finds the placeholder +account with `signedAt != null` and no sync requests, and lands on +`UnqueuedProfileSynchronization`. From there the existing machine runs. + +The order of the two middle lines is not a style choice. The placeholder has to +be in the database **before** the identity is activated: `observeProfile` starts +collecting the account the moment the identity flow emits, and an account that +is not there yet reads as `null`, which `processLocalAccount` answers with +`Landing`. It would correct itself when the row landed, through a `popUpTo(0)` +the user can see. Write the key, plant the placeholder, then re-list. + +### Committing a recovery phrase + +```kotlin +MnemonicCode.validate(words, wordlist) +val pubkey = LocalKeyManager(MnemonicCode.toSeed(words, "").byteVector(), chain, xpub).nostrPrivateKey().nostrPublicKeyHex() +rejectIfKnown(pubkey) +sovereignWalletViewModel.writeSeed(words, isRestoringWallet = true, onSeedWritten = { id -> + nostrRepository.signInToProfile(pubkey) + listIdentities { switchToWallet(id); navigate(SovereignWalletStartupRoute) } +}) +``` + +Same tail. `writeSeed` already carries `isRestoringWallet`; the only new line is +the `signInToProfile` that tells the machine this pubkey has a history to fetch +rather than a profile to create. Without it a restored wallet with no local rows +goes to `Landing` and is offered "create profile" for a key that already has one +— that is the state of restore today, and it is why the two inputs belong on one +screen. + +### Dedupe on the nostr key, not the id + +`platformWriteSeed` refuses a seed whose `WalletId` is already in the map. That +is the wrong key for this check. A mnemonic wallet and an imported nsec can be +the **same npub** with **different ids** — one is `hash160(nodeId)`, the other +`hash160(nostrPubkey)` — and the database is keyed by pubkey: `getLocalAccounts()` +is `SELECT * FROM UnsignedNostrEvent WHERE kind = 0`, joined to `Profile` on +`pubKey`. Two identities for one pubkey would share every row and disagree about +which is active. + +So `rejectIfKnown(pubkey)` checks the merged `availableIdentities` by +`nostrPublicKey`, for both inputs. The existing `WalletId` check stays; it is a +cheaper first pass for the seed case, not a replacement. + +### Landing + +`LandingScreen`'s "Sign in" already navigates to `SignInRoute`. The caption +beneath it, `sign_in_to_torch_via_nsec_or_remote_signer`, promises a remote +signer this plan does not deliver; change it to name what the screen accepts. + +### Tests + +`SignInToProfileViewModel` gets a `jvmTest` beside +`EditGroupCuratedSchemaViewModelJvmTest`: each row of the table above, in and +out — the npub for a known nsec test vector (NIP-06's own vectors give a +mnemonic → nsec → npub triple, which covers both inputs against the same +expected key); a wrong word; an `npub`; an `ncryptsec`; a duplicate. The +repository is `NostrRepository.NO_OP_NOSTR_REPOSITORY` with `signInToProfile` +overridden to record, so the test also proves the placeholder is planted exactly +once and only after the write succeeds. + +--- + +## Phase 5 — finding the profile, and not finding it + +**App. Needs nothing above it to compile, but is pointless before Phase 4 gives +it a caller.** + +### Ask the relays that would know + +`UnqueuedProfileSynchronizationViewModel.queueSynchronization` fans the sign-in +`REQ` out over `Relays.DefaultDMRelayList` — one relay, ours. Change it to +`Relays.DefaultIndexerRelayList` +([Relays.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt): +purplepag.es, indexer.coracle.social, user.kindpag.es, directory.yabu.me, +nostr1) **plus** `ephemeral`. Indexer relays exist to hold everyone's kinds 0, 3 +and 10002; that is the whole of their purpose, and it is exactly the list the +sync asks for. Our own relay stays in the set so a profile created here is found +here. + +Then follow the answer. When the sync indexes a kind 10002 for this pubkey, queue +a second, `level = 1`, `purpose = "sign-in"` request at the relays it names, for +the same kinds. That is the outbox model doing what it is for, and the +`SynchronizeNostrEventRequest.level` field is already there to mark the hop. +Bound it at one hop; the user's own relays are where their relay list is +authoritative, and anything past that is the feed, not the profile. + +### Give the machine an exit + +Today `processLocalAccount` reads + +```kotlin +unsignedNostrEvent.signedAt != null && synchronizeNostrEventRequests.isNotEmpty() -> UnsyncedProfile +``` + +and `UnsyncedProfile` shows "we are searching the internet to find your profile" +with a spinner, forever. Nothing records that a search *finished empty*. A +request goes `pending → sent` when its `REQ` is dispatched +([DatabaseNostrRepository.kt:489](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)) +and `sent → processed` only when an event arrives for it (line 532, which also +stamps the `nostrEventId`). A relay that answers with EOSE and nothing else +leaves its request at `sent` for good; the EOSE branch in +`SynchronizationViewModel` closes the subscription and writes nothing down. So +"still searching" and "searched, found nothing" are the same row. + +Two changes: + +1. **Record completion.** On EOSE, CLOSED, or the bounded timeout the + subscription already enforces, upsert any request still at `sent` with + `status = "complete"`. A request already at `processed` stays there — an + event arrived, which is the better answer. This is the one place the app + learns a relay has said everything it has, and it is currently thrown away. +2. **Observe it.** `UnsyncedProfileViewModel` watches the account's sign-in + requests; when none is still `pending` or `sent` and there is still no kind 0 + indexed for the pubkey, it moves the screen to a **not-found** state: "We could not + find a profile for this key on the relays we asked" with two actions — + *try again* (re-queue at level 0) and *set one up*. The second opens the + existing `CreateProfileScreen` form (name, bio) and calls + `nostrRepository.createNewProfile(pubkey, name, bio)` — the six-event + bootstrap a new key gets — **without** the seed generation that + `CreateProfileViewModel.createAccount` does first. The key already exists; only + the events do not. + +The relay list that "set one up" writes is the default one, which is the right +answer for a key that has never published anywhere. + +This exit is not nsec-specific. A restored recovery phrase whose profile was +never published, or a device offline at sign-in, sits on the same spinner today. +It is listed here because an imported nsec is the first path that will hit it +routinely: many nostr keys are made in a client that never wrote a kind 0. + +### Tests + +The navigation state stays `UnsyncedProfile` throughout — not-found is a state +of the *screen*, decided by its view model, so `NavigationRoutingTest` needs +nothing here. `UnsyncedProfileViewModel` gets a `jvmTest` over a fake repository +that emits the account's sign-in requests in sequence — all `pending`, then +`sent`, then `complete` — with no kind 0 indexed, and asserts the screen state +flips to not-found on the last emission and not before; and a second run where +one request goes `processed` instead, asserting it never flips at all. + +--- + +## Phase 6 — recovery, for an identity with no phrase + +**App. Needs Phase 3.** + +`KeyRecoveryScreen` offers one backup: *recovery phrase*, routing to +`RecoveryPhraseRoute`, whose view model reads `userWallet.words` out of the +decrypted seed map +([RecoveryPhraseViewModel.kt:114](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt)). +For an nsec identity there are no words, and `NoPhraseForThisWallet` is the +honest error it would show. Instead: + +- **`KeyRecoveryScreen` branches on `identity.kind`.** A mnemonic identity keeps + the phrase option. An nsec identity gets a *nostr secret key* option in its + place — `Icons.Default.Key`, `be_sure_to_keep_this_nsec_safe` as the + description, which exists — routing to a `NostrSecretRoute`. +- **`NostrSecretScreen`** is `RecoveryPhraseScreen` with the word grid replaced by + the nsec: hidden until revealed, revealed in monospace with a copy action, the + same "I have saved it" and "I understand" checkboxes, hidden again on leaving. + It reads the key from `NostrKeyManager.loadAndDecrypt` at reveal time, as the + phrase screen reads the seed file — not from the active identity — so the + screen's contract ("nothing secret held longer than it is shown") is the same + for both. The nsec is `privateKey.value.toHex().hexToNsecHrp()` — + `Credentials.kt` already has the encoder. +- **The backup flags reuse.** `isManualSeedBackupDone` and + `isSeedLossDisclaimerRead` live in the per-id `InternalPrefs` + ([InternalPrefs.kt:66](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt)), + so `showSeedBackupNotice` already means "this identity's secret is not backed + up" for whichever secret it is. The screens say "phrase" in three strings; + those become kind-aware or neutral ("recovery information", which the cloud + backup row already uses). +- **The disclaimer changes meaning.** `i_understand_that_if_i_lose_this_phone_and` + says "…I lose this profile and the funds in its wallet". An nsec identity has + no wallet; a new string for that kind, so the app is not warning about funds + it cannot hold. +- **Forget this key.** An import needs an inverse. Under the key options, for an + nsec identity only: remove the key from `nostr-keys.dat`, delete its prefs + (`DataStoreManager.deleteNodeUserPrefs` exists), hide its metadata, clear the + active identity, and go to startup. Behind a confirmation that names the npub + and says the profile stays on the relays. This is the first real "sign out" + in the app — `ActiveProfileScreen`'s button routes to + `ImplementationPendingRoute("Sign out")` — and the general case stays pending; + removing a *seed* is a wallet question and is not this plan's to answer. + +The two wallet-shaped rows on `KeyRecoveryScreen` — cloud backup, emergency kit +— are `ImplementationPendingRoute` today and stay for both kinds. + +--- + +## Phase 7 — the tests that actually prove it + +The per-phase tests above are unit tests of one seam each. Two more say the whole +thing works. + +**A restore round trip, on the jvm.** Unlock the keystore into a temp dir, write +an nsec through Phase 3's writer, list identities, activate the `NostrSecret`, +hand the `NavigationViewModel` a repository holding the `GENESIS_AT` placeholder, +and assert it lands on `UnqueuedProfileSynchronization`; then hand it the same +account with a kind 0 indexed and assert `ProfileLoaded`. This is the path a user +takes, end to end, with the network faked at the repository. + +**Nothing Lightning ran.** For the nsec activation above, assert +`platformStartupLogic` was not called. Wrap the `expect` in a counting fake for +the test, or — cheaper — assert `Identity.business == null` and that +`SovereignWalletStartupViewModel.state` stayed `Init`. An nsec identity that +quietly started a node would be a bug that nothing in the UI would reveal. + +And the one that already exists: `./gradlew :composeApp:m3Audit` over the two +new screens. The string budget is zero title-case literals and the spacing budget +is zero `.dp` in spacing positions; a sign-in form is exactly where a stray `16.dp` +and a "Sign In" arrive. + +--- + +## Phase 8 — rollout + +**Library first.** Phase 2 is a commit on `kngako/lightning-kmp-app`; the app +builds against the submodule checkout through the composite build, so it reaches +the app as a submodule pointer bump in the same commit as Phase 3. Tag it, since +JitPack consumers resolve by tag, and the README there says so. + +**Phase 1 ships alone,** before any of the rest is visible. It touches about +twenty files and changes nothing a user can see, and that is the point: if the release +after it behaves differently, the cause is in one diff. + +**Phases 3 and 4 ship together.** A build that can list an nsec identity but not +create one is fine; a build that can create one and not list it strands the user +at "Initializing…", which is where the comment at the top of +`SovereignWalletStartupScreen` says this app has stranded people before. + +**Phase 5 can ship before 4** — it fixes restore for recovery phrases too — but +should not ship *after* 4 by more than a release, for the reason given there. + +**Old builds and the new file.** A build without Phase 3 does not read +`nostr-keys.dat` and does not know the file exists. A user who imports an nsec +and then downgrades sees the identity vanish from the selector and nothing else +breaks; the file is still there for the next upgrade. `seed.dat` is untouched +throughout, so no build old or new misreads a wallet. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | identity in front of the wallet (app) | 1–2 | — | +| 2 | nostr key store (library) | 1 | — | +| 3 | listing and starting identities; the other entrance; hoist the seed writer | 2 | 1, 2 | +| 4 | the sign-in screen, both inputs, dedupe | 1–2 | 3 | +| 5 | indexer relays, one hop, the not-found exit | 1 | — | +| 6 | recovery for an nsec identity; forget this key | 1 | 3 | +| 7 | round-trip tests | 0.5 | 4, 5 | +| 8 | rollout | — | all | + +**Roughly one and a half to two focused weeks**, with Phases 1 and 2 in parallel +if two people are on it. Phase 3 is where the estimate is least reliable: it is +the first time both halves meet, and the startup screen has a history. + +## Out of scope + +- **A wallet for an nsec identity.** Not possible from the nsec, for the reason + in [The constraint](#the-constraint). Possible as a *second* secret — generate + a seed, attach it to the identity — but then one profile has two things to back + up and every recovery screen in this plan has to say which. Its own plan. +- **Starting the node lazily for mnemonic identities.** Recommended, separable, + and made small by Phase 1. See [The one decision](#the-one-decision-to-make-first). +- **`npub` sign-in (read only).** The skeleton parses it, and `signInToProfile` + would accept it. But every write path in the app assumes a key: the notary + would have nothing to sign with and the state machine would park on + `UnsignedProfile` the first time anything was queued. A read-only mode is a + product, not a branch. +- **NIP-49 `ncryptsec`.** A passphrase-encrypted nsec. quartz 1.14.0 ships + `nip49PrivKeyEnc.Nip49.decrypt`, so it is one call and a passphrase field; it + belongs in Phase 4's table once the plain nsec path is proven, and is a cheap + follow-on. +- **NIP-46 remote signer, NIP-55 Android signer.** The landing caption promises + one. Both need a signing *interface* — sign, NIP-44 encrypt, decrypt — in place + of a raw key, and `deriveHostSecretKey` (ChillDKG) needs the raw key bytes and + cannot be done through a remote signer at all. That is a different design with + a real constraint in it, and `NostrNotaryRepository`'s `isExternalSignerLogin` + stub is where it would start. +- **Tightening secrets in memory.** Both `availableWallets` today and + `StoredIdentity` here hold decrypted secrets for the view model's life. + Decrypt-at-activation applies to both kinds and should be done once, for both. +- **General sign out.** Phase 6 removes an *nsec*. Removing a seed is a wallet + operation with funds behind it, and `ActiveProfileScreen`'s button stays + pending. + +## Appendix — what was considered and rejected + +### Extending `EncryptedSeed` with a version-4 payload + +Put both kinds in `seed.dat`: a new `V2` variant (version byte 4 — 1 is single, +2 is retired, 3 is multiple) whose JSON is +`{ "": { "type": "mnemonic", "words": [...] } | { "type": "nsec", "key": "…" } }`, +and make `UserWallet` a sealed type. + +One file, one migration, one keystore alias — and rejected. The library is a +Phoenix fork whose history is visibly a stream of upstream ports; `seed.dat` is +Phoenix's format, and a fork of it is a merge conflict on every port from now on. +An older build reading the new file throws `unhandled V2 seed version=4` and +reports the *wallet* unreadable — a user who imported an nsec and downgraded +would lose access to their funds' seed until they upgraded, which is a worse +failure than the sibling file's "the nsec is not listed". And `BusinessManager.startNewBusiness(words)` +on three platforms, `RecoveryPhraseViewModel`, `WalletsSelector` and every +`UserWallet.words` site would have to learn a variant that has no words. A class +named `EncryptedSeed` should hold seeds. + +### Encoding the nsec as a mnemonic + +Thirty-two bytes is two hundred and fifty-six bits of entropy, which BIP39 +encodes as twenty-four words. So an nsec *can* be written as a valid mnemonic. +It is not the same thing as a seed whose NIP-06 leaf is that nsec — the words +would be run through `MnemonicCode.toSeed` and `LocalKeyManager` and derive a +completely different nostr key — and a store that cannot tell "words that are a +seed" from "words that are a key in disguise" is a store with a silent +misinterpretation in it. Rejected without a second look. + +### Deriving a seed from the nsec + +Not possible. Stated in [The constraint](#the-constraint); listed here so nobody +spends an afternoon confirming it. + +### The node as the key's carrier + +Leave `activeWalletInUI` as it is and build a fake `PhoenixBusiness`, or a fake +`LocalKeyManager`, for an nsec identity so the ten sites need not change. +`LocalKeyManager` is a `data class` with a seed; there is no interface to fake, +and a `PhoenixBusiness` lazily constructs eleven managers that expect a node. +Every "fake" would be a real object in a state its authors never intended, and +the ten sites are ten lines. + +### A new keystore alias for the key file + +Cleaner in principle — a compromise of one key would not be a compromise of +both. But `KeystoreHelper.getKeyForName` maps both existing aliases to **the same +key**, so the separation is already notional on Android, and a third alias means +touching that switch. Reuse, and note that separating the seed's and the key's +keystore entries is a hardening item for the wallet as much as for this. + +### Plaintext in DataStore + +`UserPrefs` is a DataStore file per wallet id and it would be one line to put the +nsec in it. It is unencrypted on disk. The seed has never been stored that way +here, and the key that signs as the user should not be the first. From bfba7e186f25936d13b4d20a11c5a67fc8becf4b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 11:39:37 +0200 Subject: [PATCH 14/71] refactor(identity): put an Identity in front of the wallet, and read the key off it Phase 1 of docs/nsec-sign-in.md. No behaviour changes; this is the type the rest of the plan stands on, landed alone so that its diff is boring. Every consumer of "the active user" in the app was the same expression, activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() ten times in eight files, and none of them wanted anything from the node but those 32 bytes. The new to.curare.compose.identity.Identity carries the nostr private key, the x-only public key (computed once), the per-id preferences and an optional PhoenixBusiness, with an IdentityKind saying whether it came from twelve words or a bare secret. It replaces fr.acinq.phoenix.data.ActiveWallet in the app rather than wrapping it: ActiveWallet is declared in the library but nothing in the library reads it, and flattening its fields into Identity is what lets the recovery screens, which read internalPrefs off the active wallet, keep working for an identity that will never have a wallet. SovereignWalletViewModel.activeWalletInUI becomes activeIdentity; setActiveWallet(walletId, business) builds the Mnemonic identity from the node it just started and delegates to a new setActiveIdentity(identity), which is the entry the nsec kind will use. The twenty-five StateFlow parameters across eighteen files become StateFlow, a rename. The ten sites become a read of the identity's key. Two got simpler: NotaryViewModel dropped the flatMapLatest from the wallet flow into the node's key-manager flow, which only existed because the key arrived after the node, and RelaysSocketManager likewise; both keep their per-key cancellation. NavigationViewModel.observeProfile lost its `business == null -> Startup` branch -- the one thing that would have made a nodeless identity loop -- and the "Couldn't get the nostrKey" dead end, since an identity is whole from the moment it exists. The unconstructed NostrNotaryRepository now takes the identity flow instead of a WalletManager, so that it compiles against the same seam. WalletId stays as the id for both kinds. Every preference in the library keys on it, and a second id type would mean a second copy of each; for an nsec identity XonlyPublicKey.toWalletId() gives hash160 of the x-only key, the same forty-hex shape as hash160 of a node id. Tests: NavigationRoutingTest keeps passing with the flow retyped, and a new jvm test pins the two things this refactor is for. A NostrSecret identity with business = null and a synced account on the device routes to ProfileLoaded rather than back to startup; and Identity.nostrPublicKey equals the key quartz derives for the same secret -- the x-only form, sixty-four hex characters, which is not what the library's own LocalKeyManager.nostrPublicKey() returns. That test runs under runBlocking with a real-time timeout because the observer waits 2.1 seconds on Dispatchers.IO, and runTest's virtual clock would time out first. The app's WalletManagerExtension.nostrPublicKey() stays: CreateProfileViewModel still derives a fresh key's pubkey with it. The plan said it could go; it was wrong by one caller. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (863 tests) and :composeApp:m3Audit. Replayed onto Mantra by docs/curated-to-mantra.md: RelaysSocketManager.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served; NavigationViewModel.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@42f3a6971a6ffd9d8c9ea70eb8945e82c71ed1f5 --- .../press/mantra/compose/identity/Identity.kt | 78 +++++++++ .../network/relays/RelaysSocketManager.kt | 22 ++- .../repository/NostrNotaryRepository.kt | 8 +- .../ui/composable/DkgApprovalScaffold.kt | 6 +- .../ui/composable/DkgJoinApprovalScreen.kt | 6 +- .../compose/ui/composable/DkgRitualScreen.kt | 8 +- .../ui/composable/DkgRound1ApprovalScreen.kt | 6 +- .../ui/composable/DkgRound2ApprovalScreen.kt | 6 +- .../ui/composable/KeyRecoveryScreen.kt | 12 +- .../ui/composable/RecoveryPhraseScreen.kt | 6 +- .../ui/composable/SelectChatRoomTypeScreen.kt | 8 +- .../composable/SelectSubgroupAdminsScreen.kt | 8 +- .../SovereignWalletStartupScreen.kt | 12 +- .../ui/composable/navigation/MantraNavHost.kt | 22 +-- .../ui/view/model/DkgRitualViewModel.kt | 15 +- .../ui/view/model/NavigationViewModel.kt | 43 ++--- .../compose/ui/view/model/NotaryViewModel.kt | 24 ++- .../ui/view/model/RecoveryPhraseViewModel.kt | 20 +-- .../view/model/SelectChatRoomTypeViewModel.kt | 11 +- .../model/SelectSubgroupAdminsViewModel.kt | 12 +- .../ui/view/model/SovereignWalletViewModel.kt | 63 ++++++-- .../ui/view/model/SynchronizationViewModel.kt | 17 +- .../ui/view/model/NavigationRoutingTest.kt | 4 +- .../dao/UnsignedNostrEventQueueJvmTest.kt | 2 +- .../model/NavigationIdentityRoutingJvmTest.kt | 152 ++++++++++++++++++ 25 files changed, 406 insertions(+), 165 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt new file mode 100644 index 00000000..7a920d38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -0,0 +1,78 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs + +/** + * Where an identity's key came from, which decides what else it can have. + * + * The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at + * `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the + * key and a wallet; a bare key yields nothing further, so an identity made from + * one can never grow a node behind it. See docs/nsec-sign-in.md. + */ +enum class IdentityKind { + /** Twelve words. The nostr key is derived from them, and so is a wallet. */ + Mnemonic, + + /** A bare nostr secret. Nothing else can be derived from it. */ + NostrSecret, +} + +/** + * The signing identity the app is running as. + * + * This is what every consumer of "the active user" reads, in place of reaching + * through the wallet -- `business.walletManager.keyManager.value.nostrPrivateKey()` + * -- for a key that only some identities derive that way. Everything downstream of + * the key is already independent of the seed: the notary and the sync pumps build + * a quartz `KeyPair` from its bytes, the ChillDKG host key is a hash of it, and the + * Marmot bundle password is `hash160` of it. None of them can tell where it came + * from, and after this type none of them need to. + * + * @param id keys the per-identity preference files and the wallet metadata. For a + * [IdentityKind.Mnemonic] identity it is the hash160 of the node id, as it always + * was; for a [IdentityKind.NostrSecret] identity it is the hash160 of the x-only + * public key ([toWalletId]). Same shape, so nothing downstream can tell the kinds + * apart by the id -- the kind is here, where it can be asked. + * @param business the running node. Non-null only for [IdentityKind.Mnemonic]. + */ +data class Identity( + val id: WalletId, + val kind: IdentityKind, + val nostrPrivateKey: PrivateKey, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + val business: PhoenixBusiness?, +) { + /** + * X-only, hex -- the form every nostr call site wants. Computed once. + * + * Not `publicKey().toHex()`: that is the 33-byte compressed encoding, sixty-six + * hex characters, and it is what the library's own `nostrPublicKey()` returns. + * Nostr keys are the 32-byte x coordinate. + */ + val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex() + + /** The key must never reach a log. `data class` would print it. */ + override fun toString(): String = "Identity(id=$id, kind=$kind, key=)" +} + +/** + * The [WalletId] of an identity that has no node id to hash. + * + * `WalletId(nodeId: PublicKey)` is the hash160 of the compressed node key; this is + * the hash160 of the x-only nostr key. The two are over different keys -- the node + * key sits at `m/50'/0'`, the nostr key at `m/44'/1237'/...` -- so a mnemonic wallet + * and its own nostr identity would never share an id even if both were registered. + * Which is exactly why a duplicate has to be caught by public key instead. + */ +fun XonlyPublicKey.toWalletId(): WalletId = + WalletId(Crypto.hash160(value).byteVector().toHex()) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index 99ba4c70..392b0b7d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -8,8 +8,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPublicKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -29,7 +28,7 @@ import kotlinx.coroutines.sync.withLock * https://github.com/PrimalHQ/primal-android-app/blob/6db2e6862239335dede4162338d7c4f10ad35031/app/src/main/kotlin/net/primal/android/networking/relays/RelaysSocketManager.kt */ class RelaysSocketManager( - private val activeWalletStateFlow: StateFlow, + private val activeIdentityStateFlow: StateFlow, private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository, @@ -61,19 +60,18 @@ class RelaysSocketManager( private fun observeActiveUserId() = scope.launch { - activeWalletStateFlow.collectLatest { activeWallet -> - if (activeWallet == null) { + activeIdentityStateFlow.collectLatest { identity -> + if (identity == null) { // TODO: Cancel all pending jobs? } - activeWallet?.business?.walletManager?.keyManager?.collectLatest { keyManager -> - keyManager?.nostrPublicKey()?.let { pubkey -> - observeRelayJobs[pubkey]?.cancel() - observeRelayJobs[pubkey] = observeRelays(pubkey) - } + // The identity carries its pubkey, so this no longer waits on the node's key + // manager to fill in. The relay observer is still a job on `scope` keyed by + // pubkey and replaced per pubkey, exactly as before. + identity?.nostrPublicKey?.let { pubkey -> + observeRelayJobs[pubkey]?.cancel() + observeRelayJobs[pubkey] = observeRelays(pubkey) } } - - } private fun observeRelays(publicKey: String): Job = diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt index 5195690d..c56aa42a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt @@ -4,8 +4,8 @@ import press.mantra.compose.cryptography.signOrThrow import press.mantra.compose.network.asClientTag import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import fr.acinq.phoenix.managers.WalletManager -import fr.acinq.phoenix.managers.nostrPrivateKey +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -23,7 +23,7 @@ import kotlinx.coroutines.sync.withLock */ class NostrNotaryRepository( private val nostrRepository: NostrRepository, - private val walletManager: WalletManager, + private val activeIdentityStateFlow: StateFlow, ) { private val scope = CoroutineScope(Dispatchers.Main) @@ -67,7 +67,7 @@ class NostrNotaryRepository( private fun findNsecOrThrow(activeUserPublicKey: String): String = runCatching { - walletManager.keyManager.value?.nostrPrivateKey()?.value?.toByteArray()?.toNsec() + activeIdentityStateFlow.value?.nostrPrivateKey?.value?.toByteArray()?.toNsec() }.getOrNull() ?: throw press.mantra.compose.exceptions.SigningKeyNotFoundException() private fun signNostrEvent(publicKey: String, event: press.mantra.compose.database.model.UnsignedNostrEvent): press.mantra.compose.database.model.NostrEvent { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index e89709cd..b3766198 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -35,7 +35,7 @@ import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.state.DkgRitualUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.composable.widgets.Decorative @@ -65,7 +65,7 @@ internal fun DkgApprovalScaffold( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -76,7 +76,7 @@ internal fun DkgApprovalScaffold( chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, activeUserPublicKey = activeUserPublicKey, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt index a1c320ea..44b28938 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -33,7 +33,7 @@ fun DkgJoinApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -45,7 +45,7 @@ fun DkgJoinApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index e246e90b..342b6702 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -73,7 +73,7 @@ import press.mantra.compose.ui.view.model.DkgRitualViewModel import press.mantra.compose.ui.view.state.DkgRitualUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.FrostSigningSession -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -135,7 +135,7 @@ fun DkgRitualScreen( /** Which of the room's ceremonies to show; null takes the newest. */ dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit = {}, @@ -146,7 +146,7 @@ fun DkgRitualScreen( activeUserPublicKey = activeUserPublicKey, dkgSessionId = dkgSessionId, initialDkgRitualUIState = initialDkgRitualUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) @@ -882,7 +882,7 @@ private fun DkgRitualScreenPreview() { ), ) ), - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt index 24970df6..ef72e22b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -37,7 +37,7 @@ fun DkgRound1ApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -49,7 +49,7 @@ fun DkgRound1ApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt index 7edd9f7a..f66d38f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt @@ -12,7 +12,7 @@ import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -38,7 +38,7 @@ fun DkgRound2ApprovalScreen( activeUserPublicKey: HexKey, chatRoomId: String, dkgSessionId: String? = null, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onDone: () -> Unit, @@ -50,7 +50,7 @@ fun DkgRound2ApprovalScreen( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, dkgSessionId = dkgSessionId, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository, onDone = onDone diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index 7d5f3396..ed56c4a1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf @@ -81,16 +81,16 @@ import press.mantra.compose.ui.theme.spacing @Composable fun KeyRecoveryScreen( activeUserPublicKey: HexKey, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, onNavigateBack: () -> Unit, onNavigateToRoute: (Route) -> Unit, ) { - val activeWallet by activeWalletStateFlow.collectAsState() + val activeIdentity by activeIdentityStateFlow.collectAsState() // The same preference the recovery phrase screen writes, so the state of the backup is // visible before the user goes looking for it. - val showBackupNotice by remember(activeWallet) { - activeWallet?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) + val showBackupNotice by remember(activeIdentity) { + activeIdentity?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) }.collectAsState(initial = false) Scaffold( @@ -305,7 +305,7 @@ private fun KeyRecoveryScreenPreview() { ) { KeyRecoveryScreen( activeUserPublicKey = "84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240", - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), onNavigateBack = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt index 904c3768..415d9973 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.backup_confirmation @@ -82,13 +82,13 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState @Composable fun RecoveryPhraseScreen( phoenixGlobal: PhoenixGlobal, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, onNavigateBack: () -> Unit, ) { val recoveryPhraseViewModel: RecoveryPhraseViewModel = viewModel( factory = RecoveryPhraseViewModel.factory( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = activeWalletStateFlow + activeIdentityStateFlow = activeIdentityStateFlow ) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index b31969d6..0d62827e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -51,7 +51,7 @@ import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.SelectChatRoomTypeViewModel import press.mantra.compose.ui.view.state.SelectChatRoomTypeUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import press.mantra.compose.ui.theme.spacing @@ -87,7 +87,7 @@ fun SelectChatRoomTypeScreen( description: String?, memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, dkgRepository: DkgRepository, @@ -100,7 +100,7 @@ fun SelectChatRoomTypeScreen( description = description, memberPublicKeys = memberPublicKeys, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, dkgRepository = dkgRepository @@ -490,7 +490,7 @@ private fun SelectChatRoomTypeScreenPreview() { ) ) ), - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index dd9028bb..94b311b8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import mantra.composeapp.generated.resources.Res @@ -81,7 +81,7 @@ fun SelectSubgroupAdminsScreen( activeUserPublicKey: HexKey, parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit @@ -91,7 +91,7 @@ fun SelectSubgroupAdminsScreen( activeUserPublicKey = activeUserPublicKey, parentChatRoomId = parentChatRoomId, initialUIState = initialUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) @@ -331,7 +331,7 @@ private fun SelectSubgroupAdminsScreenPreview() { SelectSubgroupAdminsScreen( activeUserPublicKey = "a".repeat(64), parentChatRoomId = "b".repeat(64), - activeWalletStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), + activeIdentityStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, onNavigateToRoute = {} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 02d664d9..66bacd42 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -49,8 +49,8 @@ fun SovereignWalletStartupScreen( // Deliberately no intro gate. `getShowIntro` defaults to true and nothing in this app ever // writes it back to false, so gating on it navigated away from this screen on every cold boot // — and a screen that has been left behind never gets to start the wallet. Startup has to run - // to completion here: everything downstream reads the node's key manager. An intro screen has - // to be a step inside this flow, not a detour around it. + // to completion here: everything downstream reads the active identity, and nothing sets one + // but this screen. An intro screen has to be a step inside this flow, not a detour around it. val listWalletState by sovereignWalletViewModel.listWalletState.collectAsState() @@ -84,8 +84,8 @@ fun SovereignWalletStartupScreen( val desiredWalletIdFlow = sovereignWalletViewModel.desiredWalletId.collectAsState() val desiredWalletId = desiredWalletIdFlow.value - val activeWalletFlow = sovereignWalletViewModel.activeWalletInUI.collectAsState() - val activeWallet = activeWalletFlow.value + val activeIdentityFlow = sovereignWalletViewModel.activeIdentity.collectAsState() + val activeIdentity = activeIdentityFlow.value when { availableWallets.isEmpty() -> { @@ -99,12 +99,12 @@ fun SovereignWalletStartupScreen( text = "Preparing wallet..." ) } - activeWallet != null -> { + activeIdentity != null -> { LoadingDataIndicator( text = "Opening wallet" ) LaunchedEffect(Unit) { - sovereignWalletViewModel.loadSovereignData(activeWallet.id) + sovereignWalletViewModel.loadSovereignData(activeIdentity.id) onSuccessfulStartup.invoke() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index b27a0ac2..07e08e15 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -230,7 +230,7 @@ fun MantraNavHost( val navigationViewModel: NavigationViewModel = viewModel ( factory = NavigationViewModel.factory( - activeWallet = sovereignWalletViewModel.activeWalletInUI, + activeIdentity = sovereignWalletViewModel.activeIdentity, initialNavigationUIState = NavigationUIState.Loading(), nostrRepository = databaseNostrRepository, scope = applicationIOScope @@ -239,7 +239,7 @@ fun MantraNavHost( val notaryViewModel: NotaryViewModel = viewModel( factory = NotaryViewModel.factory( - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, marmotRepository = databaseMarmotRepository, @@ -250,7 +250,7 @@ fun MantraNavHost( // TODO: Produce a notary UI Element... val synchronizationViewModel: SynchronizationViewModel = viewModel( factory = SynchronizationViewModel.factory( - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, relayRepository = databaseNostrRepository, @@ -548,7 +548,7 @@ fun MantraNavHost( SelectSubgroupAdminsScreen( activeUserPublicKey = route.activeUserPublicKey, parentChatRoomId = route.parentChatRoomId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onNavigateToRoute = { nextRoute -> @@ -563,7 +563,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onNavigateToRoute = { approvalRoute -> @@ -578,7 +578,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -591,7 +591,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -604,7 +604,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, dkgSessionId = route.dkgSessionId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, onDone = { navController.popBackStack() } @@ -618,7 +618,7 @@ fun MantraNavHost( name = route.name, description = route.description, memberPublicKeys = route.memberPublicKeys, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, @@ -815,7 +815,7 @@ fun MantraNavHost( KeyRecoveryScreen( activeUserPublicKey = route.activeUserPublicKey, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, onNavigateBack = { navController.popBackStack() }, @@ -829,7 +829,7 @@ fun MantraNavHost( composable { RecoveryPhraseScreen( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, onNavigateBack = { navController.popBackStack() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 791a0414..d369fd14 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -32,8 +32,7 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job @@ -56,7 +55,7 @@ class DkgRitualViewModel( */ val dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, ): ViewModel() { @@ -473,7 +472,7 @@ class DkgRitualViewModel( val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -513,7 +512,7 @@ class DkgRitualViewModel( val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -567,7 +566,7 @@ class DkgRitualViewModel( // guard rather than a path anybody takes. if (!loaded.isKeyStateSigned) return - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return @@ -678,7 +677,7 @@ class DkgRitualViewModel( activeUserPublicKey: HexKey, dkgSessionId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { @@ -688,7 +687,7 @@ class DkgRitualViewModel( activeUserPublicKey = activeUserPublicKey, dkgSessionId = dkgSessionId, initialDkgRitualUIState = initialDkgRitualUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt index e3fa9011..f505ffae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt @@ -8,8 +8,7 @@ import press.mantra.compose.database.model.intermdiate.LocalAccount import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import press.mantra.compose.ui.view.state.NavigationUIState import co.touchlab.kermit.Logger -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPublicKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -25,7 +24,7 @@ import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds class NavigationViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, initialNavigationUIState: NavigationUIState, val nostrRepository: press.mantra.compose.repository.NostrRepository, val scope: CoroutineScope, @@ -38,14 +37,14 @@ class NavigationViewModel( private val PENDING_BROADCAST_STATUSES = setOf("pending", "processing") fun factory( - activeWallet: StateFlow, + activeIdentity: StateFlow, initialNavigationUIState: NavigationUIState, nostrRepository: press.mantra.compose.repository.NostrRepository, scope: CoroutineScope, ): ViewModelProvider.Factory = viewModelFactory { initializer { NavigationViewModel( - activeWallet, + activeIdentity, initialNavigationUIState = initialNavigationUIState, nostrRepository = nostrRepository, scope = scope @@ -176,34 +175,22 @@ class NavigationViewModel( scope.launch(Dispatchers.IO) { delay(2_100.milliseconds) // Looking busy... - activeWalletStateFlow.collectLatest { activeWallet -> - logger.d("Active Wallet: $activeWallet") - if (activeWallet == null) { - + activeIdentityStateFlow.collectLatest { identity -> + logger.d("Active identity: $identity") + if (identity == null) { + // Nothing has been started: go and start something. _navigationUIState.getAndUpdate { NavigationUIState.StartupPhoenix } } else { - activeWallet.business.let { business -> - if (business == null) { - _navigationUIState.getAndUpdate { - NavigationUIState.StartupPhoenix - } - } else { - val activeUserPublicKey = business.walletManager.keyManager.value?.nostrPublicKey() + // The identity carries its own key, so there is no node to wait on and no + // "couldn't get the nostrKey" dead end. The branch that used to send a + // nodeless identity back to startup went with it: an identity without a + // node is now a kind of identity, not a wallet that has not finished + // starting. + logger.i("Observing: ${identity.nostrPublicKey}") - if (activeUserPublicKey == null) { - _navigationUIState.getAndUpdate { - NavigationUIState.Loading(text = "Couldn't get the nostrKey") - } - } else { - logger.i("Observing: $activeUserPublicKey") - - loadNostrProfile(activeUserPublicKey) - } - } - logger.i("Navigation UI State is Landing") - } + loadNostrProfile(identity.nostrPublicKey) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt index 2f69969a..e1dade97 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt @@ -11,19 +11,15 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import fr.acinq.phoenix.managers.nsecPassword import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import press.mantra.compose.database.model.NostrEvent @@ -31,7 +27,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant class NotaryViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: press.mantra.compose.repository.NostrRepository, val chatRepository: press.mantra.compose.repository.ChatRepository, val marmotRepository: MarmotRepository, @@ -43,7 +39,7 @@ class NotaryViewModel( fun factory( - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: press.mantra.compose.repository.NostrRepository, chatRepository: press.mantra.compose.repository.ChatRepository, marmotRepository: MarmotRepository, @@ -51,7 +47,7 @@ class NotaryViewModel( ): ViewModelProvider.Factory = viewModelFactory { initializer { NotaryViewModel( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, marmotRepository = marmotRepository, @@ -63,14 +59,14 @@ class NotaryViewModel( private val logger = Logger.withTag(TAG) - @OptIn(ExperimentalCoroutinesApi::class) private fun observeActiveNostrKey() { scope.launch { - activeWalletStateFlow - .flatMapLatest { activeWallet -> - activeWallet?.business?.walletManager?.keyManager ?: flowOf(null) - } - .map { keyManager -> keyManager?.nostrPrivateKey() } + // One flow, not a `flatMapLatest` into the node's key manager: the identity + // carries its key from the moment it exists, so there is nothing to wait for. + // `distinctUntilChanged` on the key stays -- an identity re-emitted with the same + // key must not tear down and restart the observers below. + activeIdentityStateFlow + .map { identity -> identity?.nostrPrivateKey } .distinctUntilChanged() .collectLatest { nostrPrivateKey -> if (nostrPrivateKey == null) return@collectLatest diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt index dad1108f..59ab0575 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import fr.acinq.phoenix.data.DecryptSeedResult import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers @@ -39,7 +39,7 @@ import press.mantra.compose.ui.view.state.RecoveryPhraseUIState */ class RecoveryPhraseViewModel( val phoenixGlobal: PhoenixGlobal, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, ): ViewModel() { private val logger = Logger.withTag(TAG) @@ -60,18 +60,18 @@ class RecoveryPhraseViewModel( * instead of drawing an unticked box over a ticked preference. */ @OptIn(ExperimentalCoroutinesApi::class) - val isBackupDone: StateFlow = activeWalletStateFlow + val isBackupDone: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) @OptIn(ExperimentalCoroutinesApi::class) - val isDisclaimerRead: StateFlow = activeWalletStateFlow + val isDisclaimerRead: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** True until the user has both written the phrase down and read the disclaimer. */ @OptIn(ExperimentalCoroutinesApi::class) - val showBackupNotice: StateFlow = activeWalletStateFlow + val showBackupNotice: StateFlow = activeIdentityStateFlow .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) @@ -86,7 +86,7 @@ class RecoveryPhraseViewModel( fun revealRecoveryPhrase() { if (_recoveryPhraseUIState.value is RecoveryPhraseUIState.Revealing) return - val walletId = activeWalletStateFlow.value?.id + val walletId = activeIdentityStateFlow.value?.id if (walletId == null) { logger.e { "no active wallet, cannot read a recovery phrase" } _recoveryPhraseUIState.value = RecoveryPhraseUIState.Error.NoActiveWallet @@ -128,7 +128,7 @@ class RecoveryPhraseViewModel( } fun setBackupDone(isDone: Boolean) { - val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> logger.e("could not save the backup flag", throwable) }) { @@ -137,7 +137,7 @@ class RecoveryPhraseViewModel( } fun setDisclaimerRead(isRead: Boolean) { - val internalPrefs = activeWalletStateFlow.value?.internalPrefs ?: return + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> logger.e("could not save the disclaimer flag", throwable) }) { @@ -150,12 +150,12 @@ class RecoveryPhraseViewModel( fun factory( phoenixGlobal: PhoenixGlobal, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, ): ViewModelProvider.Factory = viewModelFactory { initializer { RecoveryPhraseViewModel( phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 2d574794..a3c9d8ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -30,8 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.utils.RandomInstance -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.async @@ -58,7 +57,7 @@ class SelectChatRoomTypeViewModel( val description: String?, val memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, @@ -190,7 +189,7 @@ class SelectChatRoomTypeViewModel( return } - val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { selectChatRoomTypeUIState = SelectChatRoomTypeUIState.Error( "Couldn't read your keys. Please try again." @@ -447,7 +446,7 @@ class SelectChatRoomTypeViewModel( description: String?, memberPublicKeys: List, initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, dkgRepository: DkgRepository @@ -459,7 +458,7 @@ class SelectChatRoomTypeViewModel( description = description, memberPublicKeys = memberPublicKeys, initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, dkgRepository = dkgRepository diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt index ae4e93b2..e4ce6ff2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -12,8 +12,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.StateFlow @@ -51,7 +50,7 @@ class SelectSubgroupAdminsViewModel( val activeUserPublicKey: HexKey, val parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState, - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val chatRepository: ChatRepository, val dkgRepository: DkgRepository, ): ViewModel() { @@ -204,8 +203,7 @@ class SelectSubgroupAdminsViewModel( val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return - val nostrPrivateKey = activeWalletStateFlow.value - ?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.") return @@ -293,7 +291,7 @@ class SelectSubgroupAdminsViewModel( activeUserPublicKey: HexKey, parentChatRoomId: String, initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { @@ -302,7 +300,7 @@ class SelectSubgroupAdminsViewModel( activeUserPublicKey = activeUserPublicKey, parentChatRoomId = parentChatRoomId, initialUIState = initialUIState, - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, chatRepository = chatRepository, dkgRepository = dkgRepository ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 5229bd03..a145bd93 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -12,13 +12,13 @@ import co.touchlab.kermit.Logger import fr.acinq.lightning.logging.error import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.ActiveWallet import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import kotlinx.coroutines.CoroutineExceptionHandler @@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind sealed class WritingSeedState { data object Init : WritingSeedState() @@ -83,8 +85,17 @@ class SovereignWalletViewModel( val desiredWalletId = _desiredWalletId.asStateFlow() val startWalletImmediately = MutableStateFlow(true) - private val _activeWalletInUI = MutableStateFlow(null) - val activeWalletInUI = _activeWalletInUI.asStateFlow() + /** + * The identity the app is running as, or null while nothing has been started. + * + * This replaces what used to be a flow of the active *wallet*: every reader of it + * only ever wanted the nostr key, and reached it through the node -- + * `business.walletManager.keyManager.value.nostrPrivateKey()`. An [Identity] + * carries the key directly, so a reader no longer needs a node to exist, and an + * identity that never has one (a bare nsec) reads the same as one that does. + */ + private val _activeIdentity = MutableStateFlow(null) + val activeIdentity = _activeIdentity.asStateFlow() @OptIn(ExperimentalCoroutinesApi::class) val exchangeRates = phoenixGlobal.currencyManager.ratesFlow @@ -115,14 +126,38 @@ class SovereignWalletViewModel( listAvailableWallets(onDone = {}) } + /** Makes [identity] the one the app runs as. Everything reading [activeIdentity] follows. */ + fun setActiveIdentity(identity: Identity) { + _activeIdentity.value = identity +// scheduleAutoLock() + } + + /** + * Activates the identity a just-started node derives: the mnemonic kind, with the + * node behind it. + */ fun setActiveWallet(walletId: WalletId, business: PhoenixBusiness) { + val keyManager = business.walletManager.keyManager.value + if (keyManager == null) { + // `startNewBusiness` loads the wallet before it reports success, so a business + // handed here without a key manager is a programming error rather than a state + // to route around. Leaving the identity unset sends the UI back to startup. + log.e { "business for wallet=$walletId started without a key manager" } + return + } val dataStoreManager = DataStoreManager(business) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId) - val internalPrefs = dataStoreManager.loadInternalPrefsForWallet( walletId = walletId) - _activeWalletInUI.value = ActiveWallet(id = walletId, business = business, userPrefs = userPrefs, internalPrefs = internalPrefs) + setActiveIdentity( + Identity( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyManager.nostrPrivateKey(), + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId), + business = business, + ) + ) updateBusinessActiveInUI(walletId) -// scheduleAutoLock() } fun listAvailableWallets(onDone: () -> Unit) { @@ -186,7 +221,7 @@ class SovereignWalletViewModel( // // TODO: Run this through an expect... // viewModelScope.launch { // autoLockHandler.removeCallbacksAndMessages(null) -// val activeUserPrefs = activeWalletInUI.first()?.userPrefs ?: return@launch +// val activeUserPrefs = activeIdentity.first()?.userPrefs ?: return@launch // // val biometricLockEnabled = activeUserPrefs.getLockBiometricsEnabled.first() // val customPinLockEnabled = activeUserPrefs.getLockPinEnabled.first() @@ -198,21 +233,21 @@ class SovereignWalletViewModel( // } // } - /** Clears the active wallet and signals the startup screen to load the given [walletId]. */ + /** Clears the active identity and signals the startup screen to load the given [walletId]. */ fun switchToWallet(walletId: WalletId) { _desiredWalletId.value = walletId - _activeWalletInUI.value = null + _activeIdentity.value = null } - /** Clears the active wallet. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ + /** Clears the active identity. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ fun clearActiveWallet() { - _activeWalletInUI.value = null + _activeIdentity.value = null } - /** Resets the active wallet and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ + /** Resets the active identity and [desiredWalletId]. The UI will redirect to the startup screen with the wallets selector prompt. */ fun resetToSelector() { _desiredWalletId.value = null - _activeWalletInUI.value = null + _activeIdentity.value = null startWalletImmediately.value = false } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index c4fe428c..eba82d06 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -29,8 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd -import fr.acinq.phoenix.data.ActiveWallet -import fr.acinq.phoenix.managers.nostrPrivateKey +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -59,7 +58,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class SynchronizationViewModel( - val activeWalletStateFlow: StateFlow, + val activeIdentityStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, val relayRepository: RelayRepository, @@ -67,7 +66,7 @@ class SynchronizationViewModel( ): ViewModel() { val relaysSocketManager = RelaysSocketManager( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrSocketClientFactory = DefaultNostrSocketClientFactory, cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, relayRepository = relayRepository, @@ -130,7 +129,7 @@ class SynchronizationViewModel( private val mutex = Mutex() fun factory( - activeWalletStateFlow: StateFlow, + activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, relayRepository: RelayRepository, @@ -138,7 +137,7 @@ class SynchronizationViewModel( ): ViewModelProvider.Factory = viewModelFactory { initializer { SynchronizationViewModel( - activeWalletStateFlow = activeWalletStateFlow, + activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, chatRepository = chatRepository, relayRepository = relayRepository, @@ -188,9 +187,9 @@ class SynchronizationViewModel( init { scope.launch { - activeWalletStateFlow.collectLatest { activeWallet -> - logger.d("Synchronization ActiveWallet: $activeWallet") - activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey -> + activeIdentityStateFlow.collectLatest { identity -> + logger.d("Synchronization identity: $identity") + identity?.nostrPrivateKey?.let { nostrPrivateKey -> val keyPair = KeyPair( privKey = nostrPrivateKey.value.toByteArray() ) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt index bde74339..cb153206 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -1,7 +1,7 @@ package press.mantra.compose.ui.view.model import com.vitorpamplona.quartz.nip01Core.core.HexKey -import fr.acinq.phoenix.data.ActiveWallet +import press.mantra.compose.identity.Identity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel @@ -102,7 +102,7 @@ class NavigationRoutingTest { private fun bootedOn(vararg accounts: LocalAccount): NavigationViewModel { val scope = CoroutineScope(Job()) return NavigationViewModel( - activeWalletStateFlow = MutableStateFlow(null), + activeIdentityStateFlow = MutableStateFlow(null), initialNavigationUIState = stranded, nostrRepository = Device(accounts.toList()), scope = scope, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt index 0764b909..544ac63e 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventQueueJvmTest.kt @@ -39,7 +39,7 @@ import kotlin.time.Instant * * What is asserted here is the DAO's half: that the backlog arrives whole and in a stable * order, and that publishing some rows neither disturbs nor depends on the others. The loop - * that walks it lives in NotaryViewModel, which wants an ActiveWallet flow to stand up; the + * that walks it lives in NotaryViewModel, which wants an Identity flow to stand up; the * [drain] below is shaped like it but is this test's own, so it pins the queue rather than the * collector. */ diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt new file mode 100644 index 00000000..f8f1ccb8 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt @@ -0,0 +1,152 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.NavigationUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * An identity with no node behind it is routed like any other. + * + * `NavigationViewModel.observeProfile` used to reach the active user's pubkey through + * the wallet -- `business.walletManager.keyManager.value.nostrPublicKey()` -- and + * answered a null `business` by navigating back to startup. For a wallet that is a + * loop the startup screen breaks by starting the node; for an identity that will + * never have a node it is a loop with no exit. The [Identity] type carries the key + * itself, so the branch is gone, and this pins that it stays gone: a `NostrSecret` + * identity with a signed, synced account on the device lands on `ProfileLoaded`. + * + * A jvm test rather than a common one because [Identity] derives its public key on + * construction, which takes the secp256k1 natives, and those are on this classpath + * (see the other tests in `jvmTest` that build a `PrivateKey`). + * + * The observer waits 2.1 seconds on `Dispatchers.IO` before its first read, so this + * runs under `runBlocking` and a real-time `withTimeout`: `runTest` would advance a + * virtual clock past the timeout while the real delay had not elapsed. + */ +class NavigationIdentityRoutingJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val stranded = NavigationUIState.Loading("Introducing... Torch") + + private val privateKey = PrivateKey(ByteVector32("01".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun nostrSecretIdentity() = Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = privateKey, + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + business = null, + ) + + /** Everything `observeProfile` reads, and nothing else; every other member throws. */ + private class Device( + private val accounts: List + ) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun getLocalAccounts(): List = accounts + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = + flowOf(accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == publicKey }) + } + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + private fun syncedAccount(publicKey: HexKey) = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = at, + createdAt = at, + updatedAt = at, + savedAt = at, + ), + nostrEvent = null, + profile = Profile( + publicKey = publicKey, + nostrEventId = "e".repeat(64), + createdAt = at, + updatedAt = at, + savedAt = at, + ), + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + + @Test + fun `the identity's public key is the x-only key nostr uses`() { + val identity = nostrSecretIdentity() + + // The same key quartz derives, so the notary's KeyPair and this agree on who + // the user is. Not the 33-byte compressed form the library's own helper returns. + assertEquals( + KeyPair(privKey = privateKey.value.toByteArray()).pubKey.toHexKey(), + identity.nostrPublicKey, + ) + assertEquals(64, identity.nostrPublicKey.length) + // Same shape as a wallet's id: forty hex characters of a hash160. + assertEquals(40, identity.id.nodeIdHash.length) + } + + @Test + fun `an identity with no node behind it is routed by its account, not back to startup`() { + val identity = nostrSecretIdentity() + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(identity.nostrPublicKey))), + scope = scope, + ) + + try { + val destination = runBlocking { + withTimeout(15_000) { viewModel.navigationUIState.first { it != stranded } } + } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + destination, + "a nodeless identity was not routed by its account", + ) + } finally { + scope.cancel() + } + } +} From 24ca50ca48cb708e9670deb0053ca8c247b12cac Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 11:56:25 +0200 Subject: [PATCH 15/71] feat(identity): list and start identities from both stores, and fix the other entrance Phase 3 of docs/nsec-sign-in.md, and the submodule bump that brings Phase 2's NostrKeyManager in (lightning-kmp-app 01489b8 -> 59c11ed, branch claude/nostr-key-store). Nothing can create an nsec identity yet -- that is Phase 4 -- but from here one that exists is listed, selected, started and routed like any wallet. StoredIdentity is what the startup screen now reads: a sealed type over Mnemonic(userWallet) and NostrSecret(id, privateKey), both carrying the nostr public key, because that is the one thing the two kinds share and the one thing a duplicate check has to compare. StoredIdentity.merge folds seed.dat and nostr-keys.dat into one map keyed by WalletId; a wallet's pubkey is one more LocalKeyManager over words that SeedManager has already derived once. SovereignWalletViewModel.listAvailableWallets becomes listIdentities. It reads both files and surfaces a failure in either rather than skipping it: a corrupt nostr-keys.dat would otherwise drop every imported identity from the list without a word, and the seed file has always been handled this way. Metadata registration is unchanged -- it keys on WalletId and does not care what is behind one. SovereignWalletStartupScreen picks a StoredIdentity, and LoadWallet, the screen-lock gate, takes the identity rather than the wallet: it only ever read the id, to look up the lock preferences, and it now sits outside the branch on kind so a future lock is not something to remember to add twice. The Mnemonic branch is the old path -- startupNode, setActiveWallet. The NostrSecret branch builds the Identity directly with business = null and sets it; no platformStartupLogic, no schedulePlatformLogic, no StartupViewState. Setting it recomposes into the existing `activeIdentity != null` branch, which is what reports the startup, so the nsec path does not double-report as the mnemonic path does. WalletsSelector shows the npub on the second line for both kinds; it used to show the node id, which a bare key does not have. The other entrance. NavigationViewModel.loadNostrProfile(startupRoute) read getLocalAccounts().firstOrNull()?.profile?.publicKey which is wrong twice for this plan. It takes the first kind-0 account on the device whichever identity is active -- harmless with one wallet, wrong half the time with two, and an imported key is how a device gets its second. And it keys off the Profile row, which only the notary's *signing* writes, so a placeholder account planted by signInToProfile (or a profile created moments ago and not yet signed) read as null and was answered with Landing, while observeProfile, reading the same rows, answered the sync screen: two writers to one state in whichever order the coroutines ran, and Landing pops the back stack. It now selects the account by the active identity's pubkey, matched on the unsigned event's pubKey, and passes it straight to processLocalAccount rather than fetching it a second time. The seed writer, hoisted. platformWriteSeed was an expect with three byte-identical actuals -- android, jvm, ios -- using nothing but commonMain. It is one function now, IdentityWriter.writeMnemonic, and the second writer, IdentityWriter.writeNostrKey, sits next to it rather than being triplicated in turn. Both refuse a duplicate by nostr public key as well as by id: a wallet and an imported key can be the same npub under different ids, and the database is keyed by pubkey. The three platform files lose the copy and the imports that only it used; the jvm file's header, which described the copy, now describes what is left. Tests. NavigationRoutingTest gains the placeholder fixture -- kind 0 at GENESIS_AT, no Profile, no sync request -- and asserts it routes to UnqueuedProfileSynchronization and never Landing. The jvm routing test gains a device with two accounts and asserts the startup entrance reads the active identity's. StoredIdentityJvmTest pins the merge against NIP-06's own vector: the words "leader monkey parrot ..." derive 17162c92...cd917, the same key an nsec import of that secret produces, which is the whole basis of the duplicate check; that a wallet and its own nostr key as a bare secret land under different ids (why the writers dedupe by pubkey); and that a key filed under a pubkey it does not derive is dropped. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (870 tests) and :composeApp:m3Audit. The submodule commit is local to the branch named above and has to be pushed with this one. Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 59c11ed9, the pin this commit compiles against (git had fast-forwarded it to the newer one). Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@366b017787377a0af20b3f10f0dd1a312c885aba --- .../view/model/NavigationViewModel.android.kt | 93 -------- .../mantra/compose/identity/IdentityWriter.kt | 209 ++++++++++++++++++ .../mantra/compose/identity/StoredIdentity.kt | 98 ++++++++ .../SovereignWalletStartupScreen.kt | 86 ++++--- .../ui/composable/navigation/MantraNavHost.kt | 2 +- .../widgets/wallet/WalletsSelector.kt | 30 ++- .../ui/view/model/NavigationViewModel.kt | 27 ++- .../ui/view/model/SovereignWalletViewModel.kt | 121 ++++++---- .../ui/view/model/NavigationRoutingTest.kt | 47 ++++ .../ui/view/model/NavigationViewModel.ios.kt | 92 -------- .../ui/view/model/NavigationViewModel.jvm.kt | 104 +-------- .../compose/identity/StoredIdentityJvmTest.kt | 87 ++++++++ .../model/NavigationIdentityRoutingJvmTest.kt | 27 +++ lightning-kmp-app | 2 +- 14 files changed, 644 insertions(+), 381 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt index eaf692c1..3a90eb28 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt @@ -1,28 +1,13 @@ package press.mantra.compose.ui.view.model import press.mantra.android.MantraApplication -import press.mantra.compose.AppVersion -import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.utils.toByteVector import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.android.BusinessManager import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.security.EncryptedSeed -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) @@ -59,81 +44,3 @@ actual suspend fun saveAvailableWalletMeta( isHidden = isHidden ) } - - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) { - if (writingState !is WritingSeedState.Init) return - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - log.e("failed to write mnemonics to disk: ${e.message}") - onWritingSeedError.invoke( - WritingSeedState.Error.Generic(e) - ) - }) { - log.d("writing mnemonics to disk...") - - onWritingSeedStateWriting.invoke( - WritingSeedState.Writing(mnemonics) - ) - val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { - it.key to it.value.words - }?.toMap() - - val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() - val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) - val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) - - when { - existingSeeds == null -> { - log.e("could not load the existing seed map, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.CannotLoadSeedMap - ) - return@launch - } - existingSeeds.containsKey(newWalletId) -> { - log.i("attempting to import a seed that already exists, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists - ) - return@launch - } - else -> { - val newSeedMap = existingSeeds + (newWalletId to mnemonics) - val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) - SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) - if (isRestoringWallet) { - log.i("successfully restored wallet=$newWalletId") - } else { - log.i("successfully created wallet=$newWalletId") - } - } - } - - globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) - val dataStoreManager = DataStoreManager( - phoenixGlobal.ctx, - chain = NodeParamsManager.chain, - ) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId) - userPrefs.saveIsTorEnabled(isTorEnabled) - userPrefs.saveElectrumServer(customElectrumServer) - - viewModelScope.launch(Dispatchers.Main) { - delay(1000) - onSeedWritten(newWalletId) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt new file mode 100644 index 00000000..a6b2a484 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -0,0 +1,209 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.lightning.utils.toByteVector +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.ElectrumConfig +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.EncryptedNostrKeys +import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import press.mantra.compose.AppVersion +import press.mantra.compose.ui.view.model.WritingSeedState + +/** + * Puts a new identity's secret on disk, one function per kind, and prepares the + * per-identity preferences both kinds read. + * + * [writeMnemonic] was an `expect` with three byte-identical actuals -- android, jvm, ios -- + * using nothing but commonMain: SeedManager, EncryptedSeed, LocalKeyManager, + * DataStoreManager, AppVersion. It was an expect because something once needed to + * differ and nothing does now, so it is one function here, and the second writer sits + * next to it rather than being triplicated in turn. + * + * Both refuse a duplicate **by nostr public key**, not only by id. A wallet and an + * imported key can be the same npub under different ids -- one is hash160 of the node + * key, the other hash160 of the nostr key -- and the database is keyed by pubkey, so two + * identities for one pubkey would share every row and disagree about which is active. + */ +object IdentityWriter { + + /** The nostr public keys of every identity already on this device, or null if a store could not be read. */ + private fun knownNostrPublicKeys(phoenixGlobal: PhoenixGlobal): Set? { + val wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null + val nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null + return StoredIdentity.merge(wallets, nostrKeys).values.map { it.nostrPublicKey }.toSet() + } + + /** + * Creates the preference files a new id needs and records the choices made before + * the id was known. Tor and a custom Electrum server mean nothing to an identity + * without a wallet; they are saved anyway, because `loadUserPrefsForWallet` is what + * creates the file and the recovery screens read it. + */ + private suspend fun prepareIdentity( + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + id: WalletId, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ) { + globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) + val dataStoreManager = DataStoreManager( + phoenixGlobal.ctx, + chain = NodeParamsManager.chain, + ) + val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = id) + userPrefs.saveIsTorEnabled(isTorEnabled) + userPrefs.saveElectrumServer(customElectrumServer) + } + + /** + * Adds [mnemonics] to `seed.dat`. Callback-shaped, driving [WritingSeedState], because + * that is what the create flow and its view model were built around. + */ + fun writeMnemonic( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + writingState: WritingSeedState, + viewModelScope: CoroutineScope, + mnemonics: List, + onWritingSeedError: (WritingSeedState.Error) -> Unit, + onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, + isRestoringWallet: Boolean, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + onSeedWritten: (WalletId) -> Unit + ) { + if (writingState !is WritingSeedState.Init) return + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> + log.e("failed to write mnemonics to disk: ${e.message}") + onWritingSeedError.invoke( + WritingSeedState.Error.Generic(e) + ) + }) { + log.d("writing mnemonics to disk...") + + onWritingSeedStateWriting.invoke( + WritingSeedState.Writing(mnemonics) + ) + val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { + it.key to it.value.words + }?.toMap() + // Null when nostr-keys.dat exists and cannot be read; an empty map when it is absent. + val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + + val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() + val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) + val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + val newNostrPublicKey = keyManager.nostrPrivateKey().nostrPublicKeyHex() + + when { + existingSeeds == null || existingNostrKeys == null -> { + log.e("could not load the existing seed map, aborting...") + onWritingSeedError.invoke( + WritingSeedState.Error.CannotLoadSeedMap + ) + return@launch + } + existingSeeds.containsKey(newWalletId) -> { + log.i("attempting to import a seed that already exists, aborting...") + onWritingSeedError.invoke( + WritingSeedState.Error.SeedAlreadyExists + ) + return@launch + } + existingNostrKeys.containsKey(newNostrPublicKey) -> { + // The same npub is already here as a bare key. The id check above cannot + // see that -- different hash, different key -- so it is asked by pubkey. + log.i("attempting to import a seed whose nostr key is already here, aborting...") + onWritingSeedError.invoke( + WritingSeedState.Error.SeedAlreadyExists + ) + return@launch + } + else -> { + val newSeedMap = existingSeeds + (newWalletId to mnemonics) + val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) + SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) + if (isRestoringWallet) { + log.i("successfully restored wallet=$newWalletId") + } else { + log.i("successfully created wallet=$newWalletId") + } + } + } + + prepareIdentity(phoenixGlobal, globalPrefs, newWalletId, isTorEnabled, customElectrumServer) + + viewModelScope.launch(Dispatchers.Main) { + delay(1000) + onSeedWritten(newWalletId) + } + } + } + + sealed class WriteNostrKeyResult { + data class Written(val id: WalletId) : WriteNostrKeyResult() + + /** An identity with this nostr public key is already on the device, as a wallet or as a key. */ + data object AlreadyExists : WriteNostrKeyResult() + + /** One of the two stores exists and could not be read; nothing was written. */ + data object CannotLoadKeys : WriteNostrKeyResult() + } + + /** + * Adds [privateKey] to `nostr-keys.dat` and prepares its preferences. Suspending and + * result-shaped rather than callback-shaped: nothing was built around a state machine + * for it, and the caller is a coroutine already. Throws on an I/O or key store failure + * during the write, as [writeMnemonic]'s handler would have caught. + */ + suspend fun writeNostrKey( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + privateKey: PrivateKey, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ): WriteNostrKeyResult { + log.d("writing nostr key to disk...") + val stored = StoredIdentity.nostrSecret(privateKey) + + val known = knownNostrPublicKeys(phoenixGlobal) + val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + if (known == null || existingNostrKeys == null) { + log.e("could not load the existing keys, aborting...") + return WriteNostrKeyResult.CannotLoadKeys + } + if (stored.nostrPublicKey in known) { + log.i("attempting to import a nostr key that is already here, aborting...") + return WriteNostrKeyResult.AlreadyExists + } + + val encrypted = EncryptedNostrKeys.encrypt(existingNostrKeys + (stored.nostrPublicKey to privateKey)) + NostrKeyManager.writeToDisk(phoenixGlobal, encrypted) + log.i("successfully imported nostr key for identity=${stored.id}") + + prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) + + return WriteNostrKeyResult.Written(stored.id) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt new file mode 100644 index 00000000..6ca37701 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt @@ -0,0 +1,98 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex + +/** + * An identity this device holds the secret for, as listed before any of them is started. + * + * Two stores feed this -- `seed.dat` for wallets, `nostr-keys.dat` for bare keys -- and + * the startup screen wants one list. The nostr public key is on both kinds because it + * is the one thing they have in common and the one thing a duplicate check has to + * compare: a wallet and an imported key can be the *same* npub under different ids. + * + * On what this holds: `SovereignWalletViewModel.availableWallets` has always carried + * decrypted words for the view model's life, because starting the node needs them. + * [NostrSecret] carries its key for the same reason and is no worse; decrypting at + * activation instead would be an improvement for both kinds and belongs to both. + */ +sealed interface StoredIdentity { + val id: WalletId + val kind: IdentityKind + val nostrPublicKey: HexKey + + data class Mnemonic( + val userWallet: UserWallet, + override val nostrPublicKey: HexKey, + ) : StoredIdentity { + override val id: WalletId get() = userWallet.walletId + override val kind: IdentityKind get() = IdentityKind.Mnemonic + override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey)" + } + + data class NostrSecret( + override val id: WalletId, + override val nostrPublicKey: HexKey, + val privateKey: PrivateKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrSecret + override fun toString(): String = "StoredIdentity.NostrSecret(id=$id, npub=$nostrPublicKey, key=)" + } + + companion object { + /** + * The nostr public key a wallet's words derive. `SeedManager.loadAndDecrypt` has + * already built exactly this key manager to learn the node id, so this is the second + * derivation of it, not a new cost. + */ + fun nostrPublicKeyOf(words: List): HexKey = LocalKeyManager( + seed = MnemonicCode.toSeed(words, "").byteVector(), + chain = NodeParamsManager.chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ).nostrPrivateKey().nostrPublicKeyHex() + + fun mnemonic(userWallet: UserWallet): Mnemonic = + Mnemonic(userWallet, nostrPublicKeyOf(userWallet.words)) + + fun nostrSecret(privateKey: PrivateKey): NostrSecret { + val xOnly = privateKey.publicKey().xOnly() + return NostrSecret( + id = xOnly.toWalletId(), + nostrPublicKey = xOnly.value.toHex(), + privateKey = privateKey, + ) + } + + /** + * One map from the two stores. [nostrKeys] is keyed by x-only public key hex, as + * `nostr-keys.dat` is; the id of each is derived from that key, so a stored key + * whose map key disagrees with its own public key is dropped here too, though + * `EncryptedNostrKeys` refuses such a file before it gets this far. + */ + fun merge( + wallets: Map, + nostrKeys: Map, + ): Map { + val merged = LinkedHashMap() + wallets.values.forEach { userWallet -> merged[userWallet.walletId] = mnemonic(userWallet) } + nostrKeys.forEach { (publicKeyHex, privateKey) -> + val stored = nostrSecret(privateKey) + if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored + } + return merged + } + } +} + +/** The x-only key as bitcoin-kmp types it, from the hex nostr carries it as. */ +fun HexKey.toXonlyPublicKey(): XonlyPublicKey = XonlyPublicKey(ByteVector32(this)) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 66bacd42..b2a030e1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import fr.acinq.phoenix.data.ListWalletState -import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager @@ -26,6 +25,9 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import kotlinx.coroutines.flow.first +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -74,7 +76,7 @@ fun SovereignWalletStartupScreen( is ListWalletState.Success -> { val globalPrefs = sovereignWalletViewModel.getGlobalPrefs() - val availableWallets by sovereignWalletViewModel.availableWallets.collectAsState() + val availableIdentities by sovereignWalletViewModel.availableIdentities.collectAsState() val defaultWallet = globalPrefs.getDefaultWallet.collectAsState(null) val startWalletImmediately by sovereignWalletViewModel.startWalletImmediately.collectAsState() @@ -88,7 +90,7 @@ fun SovereignWalletStartupScreen( val activeIdentity = activeIdentityFlow.value when { - availableWallets.isEmpty() -> { + availableIdentities.isEmpty() -> { LaunchedEffect(Unit) { onNavigateToWalletLandingPage.invoke() } LoadingDataIndicator( text = "Initializing..." @@ -111,29 +113,29 @@ fun SovereignWalletStartupScreen( else -> { when (val startupState = sovereignWalletStartupViewModel.state.value) { is press.mantra.compose.ui.view.model.StartupViewState.Init -> { - var loadingWallet by remember { + var loadingIdentity by remember { mutableStateOf( when { - forceWalletId != null -> availableWallets[forceWalletId] + forceWalletId != null -> availableIdentities[forceWalletId] !startWalletImmediately -> null - availableWallets.size == 1 -> availableWallets.entries.firstOrNull()?.value - desiredWalletId != null -> availableWallets[desiredWalletId] - startWalletImmediately -> availableWallets[defaultWallet.value] + availableIdentities.size == 1 -> availableIdentities.entries.firstOrNull()?.value + desiredWalletId != null -> availableIdentities[desiredWalletId] + startWalletImmediately -> availableIdentities[defaultWallet.value] else -> null } ) } - when (val wallet = loadingWallet) { + when (val stored = loadingIdentity) { null -> { press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector( - wallets = availableWallets, + wallets = availableIdentities, globalPrefs = globalPrefs, walletsMetadata = availableWalletMetadata, activeWalletId = null, onWalletClick = { sovereignWalletViewModel.switchToWallet( - it.walletId - ); loadingWallet = it + it.id + ); loadingIdentity = it }, canEdit = false, modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space300), @@ -157,26 +159,50 @@ fun SovereignWalletStartupScreen( ) } else -> { - val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(wallet.walletId) } + val metadata = remember { availableWalletMetadata.getByWalletIdOrDefault(stored.id) } val dataStoreManager = DataStoreManager( ctx = sovereignWalletViewModel.phoenixGlobal.ctx, chain = NodeParamsManager.chain ) + // The screen-lock gate wraps both kinds. It only ever needed the + // id, to read the lock preferences, so it sits outside the branch + // and a future lock is not something to remember to add twice. LoadWallet( - userWallet = wallet, + identity = stored, metadata = metadata, - userPrefs = dataStoreManager.loadUserPrefsForWallet(wallet.walletId), + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), promptScreenLockImmediately = startWalletImmediately, - doLoadWallet = { userWallet -> - sovereignWalletStartupViewModel.startupNode(walletId = userWallet.walletId, words = userWallet.words, onStartupSuccess = { - sovereignWalletViewModel.setActiveWallet(walletId = userWallet.walletId, business = it) - onSuccessfulStartup.invoke() - }) - loadingWallet = null + doLoadWallet = { identity -> + when (identity) { + is StoredIdentity.Mnemonic -> { + sovereignWalletStartupViewModel.startupNode(walletId = identity.id, words = identity.userWallet.words, onStartupSuccess = { + sovereignWalletViewModel.setActiveWallet(walletId = identity.id, business = it) + onSuccessfulStartup.invoke() + }) + } + is StoredIdentity.NostrSecret -> { + // No node to start, so no platformStartupLogic, no + // schedulePlatformLogic and no StartupViewState: the + // identity is active the moment it is read. Setting it + // recomposes into the `activeIdentity != null` branch + // above, which is what reports the startup. + sovereignWalletViewModel.setActiveIdentity( + Identity( + id = identity.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = identity.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + business = null, + ) + ) + } + } + loadingIdentity = null }, - // only show back-to-selector button if there's more than one wallet - goToWalletSelector = availableWallets.takeIf { it.size > 1 }?.let { - { loadingWallet = null; sovereignWalletViewModel.startWalletImmediately.value = false } + // only show back-to-selector button if there's more than one identity + goToWalletSelector = availableIdentities.takeIf { it.size > 1 }?.let { + { loadingIdentity = null; sovereignWalletViewModel.startWalletImmediately.value = false } } ) } @@ -233,15 +259,15 @@ fun SovereignWalletStartupScreen( @Composable private fun BoxScope.LoadWallet( - userWallet: UserWallet, + identity: StoredIdentity, metadata: UserWalletMetadata, userPrefs: UserPrefs, promptScreenLockImmediately: Boolean, - doLoadWallet: (UserWallet) -> Unit, + doLoadWallet: (StoredIdentity) -> Unit, goToWalletSelector: (() -> Unit)? ) { - val isScreenLockRequired = produceState(initialValue = null, key1 = userWallet) { + val isScreenLockRequired = produceState(initialValue = null, key1 = identity) { val biometricLockEnabled = userPrefs.getLockBiometricsEnabled.first() val customPinLockEnabled = userPrefs.getLockPinEnabled.first() @@ -255,10 +281,10 @@ private fun BoxScope.LoadWallet( true -> { LoadingDataIndicator(text = "Unlock to continue") ScreenLockPrompt( - walletId = userWallet.walletId, + walletId = identity.id, walletName = metadata.nameOrDefault(), promptScreenLockImmediately = promptScreenLockImmediately, - onUnlock = { doLoadWallet(userWallet) }, + onUnlock = { doLoadWallet(identity) }, onLock = { }, userPrefs = userPrefs, goToWalletSelector = goToWalletSelector, @@ -267,7 +293,7 @@ private fun BoxScope.LoadWallet( false -> { LoadingDataIndicator(text = "Starting wallet...") LaunchedEffect(Unit) { - doLoadWallet(userWallet) + doLoadWallet(identity) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 07e08e15..7c1b95d5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -502,7 +502,7 @@ fun MantraNavHost( onSeedWritten() sovereignWalletViewModel.loadSovereignData(walletId) - sovereignWalletViewModel.listAvailableWallets { + sovereignWalletViewModel.listIdentities { sovereignWalletViewModel.switchToWallet(walletId) navController.navigate( route = SovereignWalletStartupRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index b21b735d..ad609bd3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -44,22 +44,23 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import press.mantra.compose.ui.composable.widgets.buttons.Clickable -import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing @Composable fun WalletsSelector( modifier: Modifier = Modifier, globalPrefs: GlobalPrefs, - wallets: Map, + wallets: Map, walletsMetadata: Map, activeWalletId: WalletId?, canEdit: Boolean, - onWalletClick: (UserWallet) -> Unit, + onWalletClick: (StoredIdentity) -> Unit, verticalArrangement: Arrangement.Vertical = Arrangement.Top, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, topContent: @Composable (() -> Unit)? = null, @@ -76,9 +77,9 @@ fun WalletsSelector( if (currentWallet != null) { item { AvailableWalletView( - userWallet = currentWallet, + identity = currentWallet, globalPrefs = globalPrefs, - metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.walletId), + metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.id), isCurrent = true, canEdit = canEdit, onClick = { onWalletClick(currentWallet) } @@ -88,14 +89,14 @@ fun WalletsSelector( Spacer(Modifier.height(MaterialTheme.spacing.space100)) } } - items(items = otherWalletsList) { (walletId, userWallet) -> + items(items = otherWalletsList) { (walletId, identity) -> AvailableWalletView( - userWallet = userWallet, + identity = identity, globalPrefs = globalPrefs, metadata = walletsMetadata.getByWalletIdOrDefault(walletId), isCurrent = false, canEdit = canEdit, - onClick = { onWalletClick(userWallet) } + onClick = { onWalletClick(identity) } ) Spacer(Modifier.height(MaterialTheme.spacing.space100)) } @@ -105,11 +106,16 @@ fun WalletsSelector( } } +/** + * The second line is the npub for both kinds. It used to be the node id, which an + * identity made from a bare key does not have -- and the npub is the identifier the rest + * of the app shows, and the one a user might actually recognise. + */ @Composable private fun AvailableWalletView( modifier: Modifier = Modifier, globalPrefs: GlobalPrefs, - userWallet: UserWallet, + identity: StoredIdentity, metadata: UserWalletMetadata, isCurrent: Boolean, canEdit: Boolean, @@ -122,7 +128,7 @@ private fun AvailableWalletView( ) // EditWalletDialog( // onDismiss = { showWalletEditDialog = false }, -// walletId = userWallet.walletId, +// walletId = identity.id, // globalPrefs = globalPrefs, // metadata = metadata, // ) @@ -134,7 +140,7 @@ private fun AvailableWalletView( if (isCurrent) { if (canEdit) showWalletEditDialog = true else return@Clickable } else { - onClick(userWallet.walletId) + onClick(identity.id) } }, shape = RoundedCornerShape(16.dp), @@ -152,7 +158,7 @@ private fun AvailableWalletView( Column { Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(MaterialTheme.spacing.space25)) - Text(text = userWallet.nodeId, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + Text(text = identity.nostrPublicKey.hexToNpubHrp(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) } } if (isCurrent && canEdit) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt index f505ffae..a7366cb9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds @@ -65,7 +64,22 @@ class NavigationViewModel( } suspend fun loadNostrProfile(startupRoute: SovereignWalletStartupRoute? = null) { - val activeUserPublicKey = nostrRepository.getLocalAccounts().firstOrNull()?.profile?.publicKey + // Which account is "ours" is the active identity's to say, when there is one: with + // two identities on the device, the first kind-0 row is the wrong one half the time. + // Matched on the unsigned event's pubKey, not `profile.publicKey` -- the Profile row is + // what the notary upserts when it *signs* the kind 0, so an account planted by + // `signInToProfile` (or one created moments ago and not yet signed) has none, and + // reading through it answered `Landing` for a key that had just been imported while + // `observeProfile`, reading the same rows, answered the sync screen. Two writers to + // one state, in whichever order the coroutines ran, and Landing pops the back stack. + val accounts = nostrRepository.getLocalAccounts() + val identity = activeIdentityStateFlow.value + val account = if (identity != null) { + accounts.firstOrNull { it.unsignedNostrEvent?.pubKey == identity.nostrPublicKey } + } else { + accounts.firstOrNull() + } + val activeUserPublicKey = account?.unsignedNostrEvent?.pubKey if (activeUserPublicKey == null) { // TODO: We can just go directly to startup witout fear... @@ -81,12 +95,9 @@ class NavigationViewModel( // `observeLocalAccount`, which turned the whole call into a no-op for anyone who // already had an account: the caller had navigated to a loading screen first and then // nothing ever moved the user off it. Every path out of here must leave the navigation - // state pointing somewhere. - processLocalAccount( - nostrRepository.observeLocalAccount( - publicKey = activeUserPublicKey - ).firstOrNull() - ) + // state pointing somewhere. `getLocalAccounts` already joined the relations this + // reads, so the account goes straight in rather than being fetched a second time. + processLocalAccount(account) } else { _navigationUIState.getAndUpdate { NavigationUIState.StartupPhoenix diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index a145bd93..2cf4d2b9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -12,12 +12,16 @@ import co.touchlab.kermit.Logger import fr.acinq.lightning.logging.error import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixGlobal +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.DecryptNostrKeysResult import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NostrKeyManager import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata @@ -35,6 +39,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.StoredIdentity sealed class WritingSeedState { data object Init : WritingSeedState() @@ -71,15 +77,19 @@ class SovereignWalletViewModel( // A StateFlow, not a `mutableStateOf`, and it has to stay one. This view model is built by // `viewModel()` during composition, so a Compose state created in its constructor is created // inside that composition's snapshot -- and a write from another thread that lands before the - // composition is applied is discarded. `listAvailableWallets` runs from `init` and, on a device + // composition is applied is discarded. `listIdentities` runs from `init` and, on a device // with no seed file, answers in about four milliseconds, which is squarely inside that window: // the write to Success was lost and the app sat on "Decrypting..." for ever. A StateFlow has no // snapshot to belong to, so the same write from the same thread at the same moment is seen. private val _listWalletState = MutableStateFlow(ListWalletState.Init) val listWalletState = _listWalletState.asStateFlow() - private val _availableWallets = MutableStateFlow>(emptyMap()) - val availableWallets = _availableWallets.asStateFlow() + /** + * Every identity this device holds a secret for, from both stores -- `seed.dat` and + * `nostr-keys.dat` -- keyed by the id the preferences and metadata use. + */ + private val _availableIdentities = MutableStateFlow>(emptyMap()) + val availableIdentities = _availableIdentities.asStateFlow() private val _desiredWalletId = MutableStateFlow(null) val desiredWalletId = _desiredWalletId.asStateFlow() @@ -123,7 +133,7 @@ class SovereignWalletViewModel( init { - listAvailableWallets(onDone = {}) + listIdentities(onDone = {}) } /** Makes [identity] the one the app runs as. Everything reading [activeIdentity] follows. */ @@ -160,59 +170,93 @@ class SovereignWalletViewModel( updateBusinessActiveInUI(walletId) } - fun listAvailableWallets(onDone: () -> Unit) { + /** + * Reads both stores and publishes the merged list, registering metadata for any id + * seen for the first time. + * + * A failure in either file is surfaced, not skipped: a corrupt `nostr-keys.dat` would + * otherwise drop every imported identity from the list without a word, and the seed + * file has always been handled this way. + */ + fun listIdentities(onDone: () -> Unit) { viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> // log.error("error when initialising startup-view: ", e) _listWalletState.value = ListWalletState.Error.Generic(e) }) { - when (val result = loadAndDecryptSeed(phoenixGlobal)) { + val wallets: Map = when (val result = loadAndDecryptSeed(phoenixGlobal)) { is DecryptSeedResult.Failure.SerializationError -> { log.error {"cannot deserialize seed file: "} _listWalletState.value = ListWalletState.Error.Serialization + return@launch } is DecryptSeedResult.Failure.DecryptionError -> { log.e("cannot decrypt seed file: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) + return@launch } is DecryptSeedResult.Failure.KeyStoreFailure -> { log.e("key store failure: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) + return@launch } is DecryptSeedResult.Failure.SeedFileUnreadable -> { log.e("aborting, unreadable seed file") _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch } is DecryptSeedResult.Failure.SeedInvalid -> { log.e("aborting, seed is invalid") _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch } + is DecryptSeedResult.Failure.SeedFileNotFound -> emptyMap() + is DecryptSeedResult.Success -> result.userWalletsMap + } - is DecryptSeedResult.Failure.SeedFileNotFound -> { - _listWalletState.value = ListWalletState.Success - _availableWallets.value = emptyMap() + val nostrKeys: Map = when (val result = NostrKeyManager.loadAndDecrypt(phoenixGlobal)) { + is DecryptNostrKeysResult.Failure.SerializationError -> { + log.e { "cannot deserialize nostr keys file" } + _listWalletState.value = ListWalletState.Error.Serialization + return@launch } - - is DecryptSeedResult.Success -> { - - val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() - result.userWalletsMap.forEach { (walletId, _) -> - if (metadataMap[walletId] == null) { - saveAvailableWalletMeta( - phoenixGlobal = phoenixGlobal, - walletId = walletId, - name = null, - avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(), - isHidden = false - ) - } - } - _availableWallets.value = result.userWalletsMap - _listWalletState.value = ListWalletState.Success - viewModelScope.launch(Dispatchers.Main) { - onDone() - } + is DecryptNostrKeysResult.Failure.DecryptionError -> { + log.e("cannot decrypt nostr keys file: ", throwable = result.cause) + _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) + return@launch } + is DecryptNostrKeysResult.Failure.KeyStoreFailure -> { + log.e("key store failure: ", throwable = result.cause) + _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) + return@launch + } + is DecryptNostrKeysResult.Failure.FileUnreadable -> { + log.e("aborting, unreadable nostr keys file") + _listWalletState.value = ListWalletState.Error.Generic(null) + return@launch + } + is DecryptNostrKeysResult.Failure.FileNotFound -> emptyMap() + is DecryptNostrKeysResult.Success -> result.keys + } + + val identities = StoredIdentity.merge(wallets, nostrKeys) + + val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() + identities.keys.forEach { walletId -> + if (metadataMap[walletId] == null) { + saveAvailableWalletMeta( + phoenixGlobal = phoenixGlobal, + walletId = walletId, + name = null, + avatar = press.mantra.compose.ui.composable.widgets.wallet.WalletAvatars.list.random(), + isHidden = false + ) + } + } + _availableIdentities.value = identities + _listWalletState.value = ListWalletState.Success + viewModelScope.launch(Dispatchers.Main) { + onDone() } } } @@ -282,10 +326,10 @@ class SovereignWalletViewModel( onSeedWriteError: (WritingSeedState.Error) -> Unit = {} ) { if (writingState is WritingSeedState.Error || writingState is WritingSeedState.WrittenToDisk) { - // A previous attempt finished; reset so platformWriteSeed does not silently skip this one. + // A previous attempt finished; reset so writeMnemonic does not silently skip this one. writingState = WritingSeedState.Init } - platformWriteSeed( + IdentityWriter.writeMnemonic( log = log, phoenixGlobal = phoenixGlobal, globalPrefs = getGlobalPrefs(), @@ -322,18 +366,3 @@ class SovereignWalletViewModel( } } } - -expect fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt index cb153206..a8aef044 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/NavigationRoutingTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.UnsignedNostrEvent import press.mantra.compose.database.model.intermdiate.LocalAccount @@ -94,6 +95,30 @@ class NavigationRoutingTest { synchronizeNostrEventRequests = emptyList(), ) + /** + * What `signInToProfile` plants for a key that already exists elsewhere: a kind 0 + * stamped `GENESIS_AT` so the notary skips it, and nothing else -- no `Profile` + * row, which only the notary's signing writes, and no sync request yet. + */ + private fun placeholderAccount() = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = GENESIS_AT, + createdAt = at, + updatedAt = at, + savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + /** * The view model's `init` starts a wallet observer that would write to the * same state after its 2.1s wait. Cancelling the scope leaves that observer @@ -161,4 +186,26 @@ class NavigationRoutingTest { // is up, so the answer is to go and start it. assertEquals(NavigationUIState.StartupPhoenix, viewModel.navigationUIState.value) } + + /** + * `loadNostrProfile` used to read the account through `profile.publicKey`. A + * placeholder has no Profile, so that read was null and the answer was Landing -- + * "go and make an account" -- for a key that had just been imported, while the + * identity observer, reading the same rows, answered the sync screen. Two writers + * to one state, and Landing pops the back stack. + */ + @Test + fun `a key just imported is sent to fetch its profile, not to make one`() = runTest { + val viewModel = bootedOn(placeholderAccount()) + + viewModel.loadNostrProfile(SovereignWalletStartupRoute) + + assertNotEquals(NavigationUIState.Landing, viewModel.navigationUIState.value) + assertEquals( + NavigationUIState.UnqueuedProfileSynchronization( + unsignedNostrEvent = placeholderAccount().unsignedNostrEvent!! + ), + viewModel.navigationUIState.value, + ) + } } diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt index 6c04560d..6d4e242c 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt @@ -1,29 +1,14 @@ package press.mantra.compose.ui.view.model -import press.mantra.compose.AppVersion -import co.touchlab.kermit.Logger import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.utils.toByteVector import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.ios.BusinessManager import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.security.EncryptedSeed -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch actual fun updateBusinessActiveInUI(walletId: WalletId) { @@ -70,80 +55,3 @@ actual suspend fun saveAvailableWalletMeta( isHidden = isHidden ) } - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) { - if (writingState !is WritingSeedState.Init) return - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - log.e("failed to write mnemonics to disk: ${e.message}") - onWritingSeedError.invoke( - WritingSeedState.Error.Generic(e) - ) - }) { - log.d("writing mnemonics to disk...") - - onWritingSeedStateWriting.invoke( - WritingSeedState.Writing(mnemonics) - ) - val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { - it.key to it.value.words - }?.toMap() - - val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() - val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) - val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) - - when { - existingSeeds == null -> { - log.e("could not load the existing seed map, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.CannotLoadSeedMap - ) - return@launch - } - existingSeeds.containsKey(newWalletId) -> { - log.i("attempting to import a seed that already exists, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists - ) - return@launch - } - else -> { - val newSeedMap = existingSeeds + (newWalletId to mnemonics) - val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) - SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) - if (isRestoringWallet) { - log.i("successfully restored wallet=$newWalletId") - } else { - log.i("successfully created wallet=$newWalletId") - } - } - } - - globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) - val dataStoreManager = DataStoreManager( - phoenixGlobal.ctx, - chain = NodeParamsManager.chain, - ) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId) - userPrefs.saveIsTorEnabled(isTorEnabled) - userPrefs.saveElectrumServer(customElectrumServer) - - viewModelScope.launch(Dispatchers.Main) { - delay(1000) - onSeedWritten(newWalletId) - } - } -} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt index 62d6645a..e076aae7 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -1,37 +1,22 @@ -// The ios actuals with one import changed. That file uses no ios API -- SeedManager, -// DataStoreManager, EncryptedSeed and LocalKeyManager are all commonMain -- so the only -// thing that had to move was BusinessManager, which is now ported to the library's jvmMain. +// The ios actuals with one import changed: BusinessManager, ported to the library's jvmMain. +// The seed writer that used to be the bulk of this file was byte-identical on all three +// platforms and now lives once, in commonMain, as IdentityWriter.writeMnemonic. // -// Kept as a copy for the same reason the ported BusinessManager is: sharing it means an -// intermediate source set between iosMain and jvmMain, which is a change to how the module -// is wired rather than to what it does. +// What is left is kept as a copy for the same reason the ported BusinessManager is: sharing +// it means an intermediate source set between iosMain and jvmMain, which is a change to how +// the module is wired rather than to what it does. package press.mantra.compose.ui.view.model -import press.mantra.compose.AppVersion -import co.touchlab.kermit.Logger import fr.acinq.bitcoin.Chain -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.lightning.utils.toByteVector import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.DecryptSeedResult -import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.jvm.BusinessManager import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.SeedManager -import fr.acinq.phoenix.security.EncryptedSeed -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch actual fun updateBusinessActiveInUI(walletId: WalletId) { @@ -78,80 +63,3 @@ actual suspend fun saveAvailableWalletMeta( isHidden = isHidden ) } - -actual fun platformWriteSeed( - log: Logger, - phoenixGlobal: PhoenixGlobal, - globalPrefs: GlobalPrefs, - writingState: WritingSeedState, - viewModelScope: CoroutineScope, - mnemonics: List, - onWritingSeedError: (WritingSeedState.Error) -> Unit, - onWritingSeedStateWriting: (WritingSeedState.Writing) -> Unit, - isRestoringWallet: Boolean, - isTorEnabled: Boolean, - customElectrumServer: ElectrumConfig.Custom?, - onSeedWritten: (WalletId) -> Unit -) { - if (writingState !is WritingSeedState.Init) return - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - log.e("failed to write mnemonics to disk: ${e.message}") - onWritingSeedError.invoke( - WritingSeedState.Error.Generic(e) - ) - }) { - log.d("writing mnemonics to disk...") - - onWritingSeedStateWriting.invoke( - WritingSeedState.Writing(mnemonics) - ) - val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { - it.key to it.value.words - }?.toMap() - - val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() - val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) - val newWalletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) - - when { - existingSeeds == null -> { - log.e("could not load the existing seed map, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.CannotLoadSeedMap - ) - return@launch - } - existingSeeds.containsKey(newWalletId) -> { - log.i("attempting to import a seed that already exists, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists - ) - return@launch - } - else -> { - val newSeedMap = existingSeeds + (newWalletId to mnemonics) - val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) - SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) - if (isRestoringWallet) { - log.i("successfully restored wallet=$newWalletId") - } else { - log.i("successfully created wallet=$newWalletId") - } - } - } - - globalPrefs.saveLastUsedAppCode(AppVersion.versionCode) - val dataStoreManager = DataStoreManager( - phoenixGlobal.ctx, - chain = NodeParamsManager.chain, - ) - val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = newWalletId) - userPrefs.saveIsTorEnabled(isTorEnabled) - userPrefs.saveElectrumServer(customElectrumServer) - - viewModelScope.launch(Dispatchers.Main) { - delay(1000) - onSeedWritten(newWalletId) - } - } -} \ No newline at end of file diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt new file mode 100644 index 00000000..b1a44869 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt @@ -0,0 +1,87 @@ +package press.mantra.compose.identity + +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals + +/** + * The merged list the startup screen reads, and the one derivation it rests on. + * + * The vector is NIP-06's own: twelve words, the key at `m/44'/1237'/0'/0/0`, and the + * x-only public key. It pins that a wallet's `nostrPublicKey` is the same key an nsec + * import of that wallet's nostr secret would produce -- which is the whole basis of the + * duplicate check in `IdentityWriter`. + */ +class StoredIdentityJvmTest { + + private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ") + private val nip06PrivateKey = PrivateKey(ByteVector32("7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a")) + private val nip06PublicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + + private val wallet = UserWallet( + walletId = WalletId("ab".repeat(20)), + nodeId = "02" + "cd".repeat(32), + words = words, + ) + private val bareKey = PrivateKey(ByteVector32("02".repeat(32))) + + @Test + fun `a wallet's nostr key is the NIP-06 derivation of its words`() { + assertEquals(nip06PublicKey, StoredIdentity.nostrPublicKeyOf(words)) + assertEquals(nip06PublicKey, StoredIdentity.nostrSecret(nip06PrivateKey).nostrPublicKey) + } + + @Test + fun `both stores land in one map, each under its own id`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + nostrKeys = mapOf(bareKey.publicKey().xOnly().value.toHex() to bareKey), + ) + + assertEquals(2, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) + assertEquals(IdentityKind.Mnemonic, mnemonic.kind) + + val secret = assertIs(merged[bareKey.publicKey().xOnly().toWalletId()]) + assertEquals(IdentityKind.NostrSecret, secret.kind) + assertEquals(40, secret.id.nodeIdHash.length, "same shape as a wallet's id") + assertNotEquals(wallet.walletId, secret.id) + } + + /** + * The same npub as a wallet, imported as a bare key, gets a *different* id -- one + * hash is of the node key, the other of the nostr key -- so a map keyed by id holds + * both. That is not a bug in the merge; it is why the writers dedupe by public key. + */ + @Test + fun `a wallet and its own nostr key as a bare secret do not collide by id`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + nostrKeys = mapOf(nip06PublicKey to nip06PrivateKey), + ) + + assertEquals(2, merged.size) + assertEquals(1, merged.values.map { it.nostrPublicKey }.toSet().size, "one npub, twice") + } + + @Test + fun `a key filed under a public key it does not derive is dropped`() { + val merged = StoredIdentity.merge( + wallets = emptyMap(), + nostrKeys = mapOf(nip06PublicKey to bareKey), + ) + + assertEquals(emptyMap(), merged) + } + + @Test + fun `the nsec form and the hex form name the same key`() { + assertEquals(nip06PublicKey.toXonlyPublicKey().value.toHex(), nip06PublicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt index f8f1ccb8..ce80da88 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt @@ -27,6 +27,7 @@ import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.toWalletId import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStartupRoute import press.mantra.compose.ui.view.state.NavigationUIState import kotlin.test.Test import kotlin.test.assertEquals @@ -149,4 +150,30 @@ class NavigationIdentityRoutingJvmTest { scope.cancel() } } + + /** + * The startup screen's own entrance, `loadNostrProfile(startupRoute)`, used to take + * the *first* kind-0 account on the device whichever identity was active. With one + * wallet that was harmless; with two it is the wrong account half the time, and an + * imported key is how a device gets its second identity. + */ + @Test + fun `with two accounts on the device, the startup entrance reads the active identity's`() { + val identity = nostrSecretIdentity() + val someoneElse = "a".repeat(64) + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(someoneElse), syncedAccount(identity.nostrPublicKey))), + scope = scope, + ).also { scope.cancel() } + + runBlocking { viewModel.loadNostrProfile(SovereignWalletStartupRoute) } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + viewModel.navigationUIState.value, + ) + } } diff --git a/lightning-kmp-app b/lightning-kmp-app index 84cc44c8..59c11ed9 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 84cc44c855e3d173e26016b807c449153a3af597 +Subproject commit 59c11ed926ac05c820b98afb4cc961a5a3506310 From 2e31daf1170e9d6ed4ef1534eabded285344cfea Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 12:05:35 +0200 Subject: [PATCH 16/71] feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence about alpha testing and becomes the screen: one field, two things it accepts, and the npub it signs as shown before anything is written. One field, because a user does not choose an input type -- they paste what they have -- and the two shapes are unambiguous: words have spaces, keys do not. CredentialParser is that decision as a pure function. Words go through MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed nsec or sixty-four hex characters decode to a PrivateKey and must pass isValid(). The two shapes a reasonable person might paste and that cannot work are refused by name rather than lumped in with garbage: an npub is PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey (NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is, and a capital is a paste artefact worth showing rather than silently fixing. Problems with the input stay on the field, as supporting text, while the prompt is still showing; problems after confirming -- the key was already here, the write failed -- are a state of the screen, through ErrorState with a retry back to the field. Four states, wrapped in ScreenStateTransition. Confirm shows the npub in full, in monospace, and which kind was recognised, with the one consequence that differs: a recovery phrase also restores the wallet, a bare key comes with none. The secret itself is never echoed. SignInToProfileViewModel.commit is the effect, and its order is the point. The secret is written to its store first -- IdentityWriter.writeNostrKey for an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a phrase, both injected as functions so the commit can be driven without a key store -- and only on success is the placeholder account planted with nostrRepository.signInToProfile. The placeholder before the write would send the user to fetch a profile they can never sign for; the write before the placeholder is what the nav host then does. Both writers already refuse a duplicate by nostr public key, so a wallet's own nostr secret pasted as an nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare. The nav host wires the route with the same tail the create flow uses after writeSeed: re-list identities, select the new one, go to startup with the form popped, so back does not return to a field holding a secret. Startup finds the identity, activates it, and NavigationViewModel takes it from the placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from both entrances. Strings: nineteen new, sentence case, including the refusals as sentences that say what to do instead. The six Torch-era sign-in strings nothing referenced any more are gone, and the landing caption no longer promises a remote signer this does not deliver. Tests. CredentialParserJvmTest is every row of the table in and out, all three spellings of NIP-06's vector -- words, nsec, hex, with and without the nostr: prefix, in either case -- asserted to land on the same public key, which is the equality the duplicate check rests on; and each refusal by name. SignInToProfileViewModelJvmTest records the effects of a commit and asserts their order and count: write then plant for both kinds, and no planting after a refusal or a throw. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885 tests) and :composeApp:m3Audit, which found no spacing literal or title-case string in the new screen. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@220616019e6466a3fb36a5bf2b482f20d93c01c5 --- .../composeResources/values/strings.xml | 26 +- .../compose/identity/CredentialParser.kt | 122 +++++++ .../compose/ui/composable/LandingScreen.kt | 4 +- .../compose/ui/composable/SignInScreen.kt | 305 ++++++++++++++++-- .../ui/composable/navigation/MantraNavHost.kt | 31 +- .../ui/view/model/SignInToProfileViewModel.kt | 177 +++++++--- .../ui/view/model/SovereignWalletViewModel.kt | 14 + .../ui/view/state/SignInToProfileUIState.kt | 35 +- .../identity/CredentialParserJvmTest.kt | 91 ++++++ .../model/SignInToProfileViewModelJvmTest.kt | 119 +++++++ 10 files changed, 834 insertions(+), 90 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 14648578..b9b5da9e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -91,7 +91,6 @@ Encrypt and back your recovery information up to your Google Drive or iCloud. Enter the name you want to use for your group Enter the name you want to use for your profile - Enter the nsec or npub (read only) that you want to sign in as Enter the translation for this chunk Events are indexed so that we can deliver a premium local first experience. Everything else @@ -168,7 +167,6 @@ No versions. Not now Nothing was created and no key exists. It is safe to run it again. - nsec, npub, nip-05 (static address) Open chat Original Original text @@ -228,10 +226,6 @@ This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature. Sign Sign in - Sign in is not yet available while Mantra is in alpha testing. - Sign in to nsec - Sign in to Mantra via nsec, or remote signer - Sign in with an npub Sign out Sign with the group's key Signed their part @@ -576,4 +570,24 @@ Content must be text. The content isn't a profile. A kind 0 carries a JSON object of profile fields. This schema can't be signed as it is: + + A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec. + An npub is a public key. Mantra needs the secret key, the nsec, to sign as you. + Enter a recovery phrase or an nsec to sign in. + Paste + Recognised as a nostr secret key. No wallet comes with it. + Recognised as a recovery phrase. It also restores the wallet. + Recovery phrase or nsec + Sign in with a recovery phrase or an nsec + Signed in + Signing you in… + That is not a recovery phrase or a nostr key. + That nostr secret key is not valid. + That recovery phrase is not valid. Check each word and the order. + The key could not be saved on this device. + Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm. + This key is already on this device. + Twelve words, or nsec1… + Use a different key + You are signing in as diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt new file mode 100644 index 00000000..ffa2f11c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt @@ -0,0 +1,122 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.MnemonicLanguage + +/** + * What a user pasted into the sign-in field, once it has been recognised. + * + * Both carry the nostr public key, because the confirm step shows it and the duplicate + * check compares it. Neither prints its secret. + */ +sealed interface SignInCredential { + val nostrPublicKey: HexKey + + /** Twelve (or more) words: a wallet, and the nostr key derived from it. */ + data class RecoveryPhrase( + val words: List, + override val nostrPublicKey: HexKey, + ) : SignInCredential { + override fun toString(): String = "RecoveryPhrase(npub=$nostrPublicKey, words=***)" + } + + /** A bare nostr secret. No wallet comes with it. */ + data class NostrSecret( + val privateKey: PrivateKey, + override val nostrPublicKey: HexKey, + ) : SignInCredential { + override fun toString(): String = "NostrSecret(npub=$nostrPublicKey, key=)" + } +} + +/** Why an input was not accepted. The screen turns each into a sentence. */ +enum class CredentialProblem { + /** Nothing was entered. */ + Empty, + + /** Not words, not an nsec, not hex: nothing this screen knows how to read. */ + NotRecognised, + + /** Words, but the checksum or the wordlist says no: a wrong word, or one out of order. */ + InvalidPhrase, + + /** An nsec or hex that does not decode to a valid secp256k1 secret. */ + InvalidKey, + + /** An npub. A public key cannot sign, and every write path here assumes a key that can. */ + PublicKeyOnly, + + /** An ncryptsec (NIP-49). Decrypting one is a follow-on; say so rather than fail opaquely. */ + EncryptedKey, + + /** An identity with this nostr public key is already on the device, as a wallet or a key. */ + AlreadyOnThisDevice, + + /** The secret could not be written to disk. */ + CouldNotWrite, +} + +/** + * Recognises a pasted credential. Pure, so it can be pinned row by row. + * + * A user does not choose an input type; they paste what they have. The two accepted + * shapes are unambiguous -- words have spaces, keys do not -- and the two refused + * shapes are named for what they are rather than lumped in with garbage, because a + * user who pasted an npub or an ncryptsec did something reasonable and should be told + * what to do instead. + */ +object CredentialParser { + + sealed interface Result { + data class Recognised(val credential: SignInCredential) : Result + data class Refused(val problem: CredentialProblem) : Result + } + + private val hex64 = Regex("[0-9a-f]{64}") + + fun parse(raw: String): Result { + val text = raw.trim() + if (text.isEmpty()) return Result.Refused(CredentialProblem.Empty) + + // Whitespace anywhere inside means words. Not lower-cased: the wordlist already + // is, and a capitalised word is a paste artefact worth showing rather than fixing. + val words = text.split(Regex("\\s+")) + if (words.size > 1) return recoveryPhrase(words) + + // Bech32 is case-insensitive as a whole but rejects mixed case, and a `nostr:` + // prefix is how a key arrives from a link. + val token = text.removePrefix("nostr:").lowercase() + return when { + token.startsWith("npub1") -> Result.Refused(CredentialProblem.PublicKeyOnly) + token.startsWith("ncryptsec1") -> Result.Refused(CredentialProblem.EncryptedKey) + token.startsWith("nsec1") -> nsec(token) + hex64.matches(token) -> secret(runCatching { PrivateKey.fromHex(token) }.getOrNull()) + else -> Result.Refused(CredentialProblem.NotRecognised) + } + } + + private fun recoveryPhrase(words: List): Result = try { + MnemonicCode.validate(mnemonics = words, wordlist = MnemonicLanguage.English.wordlist()) + Result.Recognised(SignInCredential.RecoveryPhrase(words, StoredIdentity.nostrPublicKeyOf(words))) + } catch (e: Exception) { + Result.Refused(CredentialProblem.InvalidPhrase) + } + + private fun nsec(token: String): Result { + val bytes = runCatching { + val (hrp, data) = Bech32.decodeBytes(token) + require(hrp == "nsec") { "not an nsec" } + data + }.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey) + return secret(runCatching { PrivateKey(bytes) }.getOrNull()) + } + + private fun secret(privateKey: PrivateKey?): Result { + if (privateKey == null || !privateKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) + return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex())) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt index 9b594afc..acf73b4d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt @@ -27,7 +27,7 @@ import mantra.composeapp.generated.resources.keep_the_feed_alive import mantra.composeapp.generated.resources.learn_more import mantra.composeapp.generated.resources.mantra import mantra.composeapp.generated.resources.sign_in -import mantra.composeapp.generated.resources.sign_in_to_torch_via_nsec_or_remote_signer +import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_or_an_nsec import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent @@ -82,7 +82,7 @@ fun LandingScreen( } Text( - text = stringResource(Res.string.sign_in_to_torch_via_nsec_or_remote_signer), + text = stringResource(Res.string.sign_in_with_a_recovery_phrase_or_an_nsec), modifier = Modifier.padding(MaterialTheme.spacing.space125), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index 8bc2e213..f60352fd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -3,45 +3,304 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import press.mantra.compose.ui.theme.spacing +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_password_protected_key_ncryptsec_is_not +import mantra.composeapp.generated.resources.an_npub_is_a_public_key_mantra_needs_the +import mantra.composeapp.generated.resources.back +import mantra.composeapp.generated.resources.enter_a_recovery_phrase_or_an_nsec_to_sign_in +import mantra.composeapp.generated.resources.next +import mantra.composeapp.generated.resources.paste +import mantra.composeapp.generated.resources.recognised_as_a_nostr_secret_key_no_wallet +import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_also +import mantra.composeapp.generated.resources.recovery_phrase_or_nsec +import mantra.composeapp.generated.resources.sign_in +import mantra.composeapp.generated.resources.signed_in +import mantra.composeapp.generated.resources.signing_you_in +import mantra.composeapp.generated.resources.that_is_not_a_recovery_phrase_or_a_nostr_key +import mantra.composeapp.generated.resources.that_nostr_secret_key_is_not_valid +import mantra.composeapp.generated.resources.that_recovery_phrase_is_not_valid_check +import mantra.composeapp.generated.resources.the_key_could_not_be_saved_on_this_device +import mantra.composeapp.generated.resources.the_words_or_the_key_never_leave_this_device +import mantra.composeapp.generated.resources.this_key_is_already_on_this_device +import mantra.composeapp.generated.resources.twelve_words_or_nsec1 +import mantra.composeapp.generated.resources.use_a_different_key +import mantra.composeapp.generated.resources.you_are_signing_in_as +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.WalletId import org.jetbrains.compose.resources.stringResource -import mantra.composeapp.generated.resources.sign_in_is_not_yet_available_while_mantra_is +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SignInCredential +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.WritingSeedState +import press.mantra.compose.ui.view.state.SignInToProfileUIState -// The message is the whole screen while sign in is held back, so it is centred in -// the window rather than given the leading edge the readable column normally keeps. +/** + * Signing in with a secret the user already has. + * + * One field, two things it accepts -- a recovery phrase or an nsec -- because a user + * does not choose an input type, they paste what they have. What was recognised, and + * the npub it signs as, is shown before anything is written; that is the step that + * catches the wrong key. Problems with the input stay on the field; problems after + * confirming are a state of the screen. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SignInToProfileScreen() { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> - Column( - modifier = Modifier.fillMaxSize().padding(innerPadding) - .readableContent() - .padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(Res.string.sign_in_is_not_yet_available_while_mantra_is), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center +fun SignInToProfileScreen( + nostrRepository: NostrRepository, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + writeRecoveryPhrase: ( + words: List, + onWritten: (WalletId) -> Unit, + onError: (WritingSeedState.Error) -> Unit, + ) -> Unit, + onNavigateBack: () -> Unit, + onSignedIn: (WalletId) -> Unit, +) { + val viewModel: SignInToProfileViewModel = viewModel( + factory = SignInToProfileViewModel.factory( + nostrRepository = nostrRepository, + writeNostrKey = writeNostrKey, + writeRecoveryPhrase = writeRecoveryPhrase, + ) + ) + + // Both composable, and the handler that shows this is not: read above it. + val notify = rememberNotifier(rememberCoroutineScope()) + val signedIn = stringResource(Res.string.signed_in) + + // imePadding: there is a text field, and without it the keyboard covers it. + Scaffold( + modifier = Modifier.imePadding(), + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.sign_in)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + } ) } + ) { innerPadding -> + ScreenStateTransition( + state = viewModel.uiState.value, + modifier = Modifier.fillMaxSize().padding(innerPadding), + label = "sign in", + ) { uiState -> + when (val state = uiState) { + is SignInToProfileUIState.InputPrompt -> InputPrompt(viewModel) + + is SignInToProfileUIState.Confirm -> Confirm( + credential = state.credential, + onConfirm = { + viewModel.confirm(state.credential) { id -> + notify(signedIn) + onSignedIn(id) + } + }, + onUseADifferentKey = { viewModel.useADifferentKey() }, + ) + + is SignInToProfileUIState.Committing -> LoadingDataIndicator( + text = stringResource(Res.string.signing_you_in) + ) + + is SignInToProfileUIState.Error -> ErrorState( + message = problemMessage(state.problem), + onRetry = { viewModel.retry() }, + ) + } + } } } +@Composable +private fun InputPrompt(viewModel: SignInToProfileViewModel) { + val field = viewModel.signInToProfileFormState.textFieldForm.textFieldState + val problem = viewModel.inputProblem.value + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .readableContent() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + Text( + text = stringResource(Res.string.the_words_or_the_key_never_leave_this_device), + style = MaterialTheme.typography.bodyMedium, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = field, + isError = problem != null, + supportingText = problem?.let { { Text(text = problemMessage(it)) } }, + // Two lines shows a whole phrase without scrolling and an nsec on one; four is + // room for a phrase pasted with line breaks. + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 2, maxHeightInLines = 4), + // A key is not prose: no capitalisation, no autocorrect, no suggestions rewriting + // a word of the phrase. Monospace so an nsec can be read back character by character. + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + ), + textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), + label = { Text(text = stringResource(Res.string.recovery_phrase_or_nsec), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.twelve_words_or_nsec1), maxLines = 1) }, + trailingIcon = { + IconButton( + onClick = { + clipboardManager.getText()?.text?.let { field.setTextAndPlaceCursorAtEnd(it) } + } + ) { + Icon( + Icons.Default.ContentPaste, + contentDescription = stringResource(Res.string.paste) + ) + } + }, + ) + + Button( + modifier = Modifier.align(Alignment.End), + onClick = { viewModel.recognise() }, + ) { + Text(text = stringResource(Res.string.next)) + } + } +} + +@Composable +private fun Confirm( + credential: SignInCredential, + onConfirm: () -> Unit, + onUseADifferentKey: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .readableContent() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + Text( + text = stringResource(Res.string.you_are_signing_in_as), + style = MaterialTheme.typography.bodyMedium, + ) + + // In full, so a wrong paste can be seen for what it is. The secret is never echoed. + Text( + text = credential.nostrPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + ) { + // The label beside each icon says what it is; the icon only decorates it. + when (credential) { + is SignInCredential.RecoveryPhrase -> { + Icon(Icons.Default.Spellcheck, contentDescription = Decorative) + Text( + text = stringResource(Res.string.recognised_as_a_recovery_phrase_it_also), + style = MaterialTheme.typography.bodyMedium, + ) + } + is SignInCredential.NostrSecret -> { + Icon(Icons.Default.Key, contentDescription = Decorative) + Text( + text = stringResource(Res.string.recognised_as_a_nostr_secret_key_no_wallet), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + ) { + Button(onClick = onConfirm) { + Text(text = stringResource(Res.string.sign_in)) + } + TextButton(onClick = onUseADifferentKey) { + Text(text = stringResource(Res.string.use_a_different_key)) + } + } + } +} + +/** Each refusal is a sentence that says what to do instead, not a code. */ +@Composable +private fun problemMessage(problem: CredentialProblem): String = stringResource( + when (problem) { + CredentialProblem.Empty -> Res.string.enter_a_recovery_phrase_or_an_nsec_to_sign_in + CredentialProblem.NotRecognised -> Res.string.that_is_not_a_recovery_phrase_or_a_nostr_key + CredentialProblem.InvalidPhrase -> Res.string.that_recovery_phrase_is_not_valid_check + CredentialProblem.InvalidKey -> Res.string.that_nostr_secret_key_is_not_valid + CredentialProblem.PublicKeyOnly -> Res.string.an_npub_is_a_public_key_mantra_needs_the + CredentialProblem.EncryptedKey -> Res.string.a_password_protected_key_ncryptsec_is_not + CredentialProblem.AlreadyOnThisDevice -> Res.string.this_key_is_already_on_this_device + CredentialProblem.CouldNotWrite -> Res.string.the_key_could_not_be_saved_on_this_device + } +) + @ConformancePreviews @Composable private fun SignInToProfileScreenPreview() { @@ -49,7 +308,13 @@ private fun SignInToProfileScreenPreview() { Surface( modifier = Modifier.fillMaxSize() ) { - SignInToProfileScreen() + SignInToProfileScreen( + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + writeNostrKey = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }, + writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, + onNavigateBack = {}, + onSignedIn = {}, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 7c1b95d5..976bc510 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -662,7 +662,36 @@ fun MantraNavHost( ) } composable { - SignInToProfileScreen() + SignInToProfileScreen( + nostrRepository = databaseNostrRepository, + writeNostrKey = { privateKey -> sovereignWalletViewModel.writeNostrKey(privateKey) }, + writeRecoveryPhrase = { words, onWritten, onError -> + sovereignWalletViewModel.writeSeed( + words, + isRestoringWallet = true, + onSeedWritten = onWritten, + onSeedWriteError = onError, + ) + }, + onNavigateBack = { + navController.popBackStack() + }, + // The tail the create flow uses after writeSeed: re-list, select, go to + // startup. Startup finds the new identity, activates it, and the navigation + // machine takes it from the placeholder account the sign-in planted. The + // form is popped so that back does not return to a field holding a secret. + onSignedIn = { walletId -> + sovereignWalletViewModel.loadSovereignData(walletId) + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToWallet(walletId) + navController.navigate( + route = SovereignWalletStartupRoute + ) { + popUpTo(0) + } + } + }, + ) } composable { backStackEntry -> backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt index 27c90689..df24172a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt @@ -8,30 +8,66 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.entities.Entity -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.WalletId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.CredentialParser +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SignInCredential +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.SignInToProfileUIState +import press.mantra.compose.ui.view.state.form.SignInToProfileFormState +/** + * Signs in with a secret the user already has: a recovery phrase or an nsec. + * + * The two writers are injected as functions rather than reached for, so the commit can + * be driven in a test without a key store, and because the recovery-phrase writer is + * `SovereignWalletViewModel.writeSeed`, which lives in another view model and drives a + * state machine of its own. + * + * What this does not do is decide where the user goes next. It writes the secret, plants + * the placeholder account, and hands the new id to [onSignedIn]; the nav host re-lists + * identities, selects that one and goes to startup, which is the same tail the create + * flow uses. The **order** inside [commit] is load-bearing: the account has to be in the + * database before the identity is activated, because `NavigationViewModel.observeProfile` + * starts reading the moment the identity flow emits, and an account that is not there + * yet reads as "go and make one". + */ class SignInToProfileViewModel( - initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState, - val signInToProfileFormState: press.mantra.compose.ui.view.state.form.SignInToProfileFormState = press.mantra.compose.ui.view.state.form.SignInToProfileFormState(), - val nostrRepository: press.mantra.compose.repository.NostrRepository -): ViewModel() { + private val nostrRepository: NostrRepository, + private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + private val writeRecoveryPhrase: ( + words: List, + onWritten: (WalletId) -> Unit, + onError: (WritingSeedState.Error) -> Unit, + ) -> Unit, + val signInToProfileFormState: SignInToProfileFormState = SignInToProfileFormState(), +) : ViewModel() { + companion object { private const val TAG = "SignInToProfileViewModel" fun factory( - initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState, - nostrRepository: press.mantra.compose.repository.NostrRepository + nostrRepository: NostrRepository, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + writeRecoveryPhrase: ( + words: List, + onWritten: (WalletId) -> Unit, + onError: (WritingSeedState.Error) -> Unit, + ) -> Unit, ): ViewModelProvider.Factory = viewModelFactory { initializer { SignInToProfileViewModel( - initialWriteNewNoteUIState, - nostrRepository = nostrRepository + nostrRepository = nostrRepository, + writeNostrKey = writeNostrKey, + writeRecoveryPhrase = writeRecoveryPhrase, ) } } @@ -39,51 +75,98 @@ class SignInToProfileViewModel( private val logger = Logger.withTag(TAG) - val isActionPending: MutableState = mutableStateOf(false) + val uiState: MutableState = mutableStateOf(SignInToProfileUIState.InputPrompt) - var signInToProfileUIState: MutableState = mutableStateOf( - initialWriteNewNoteUIState - ) + /** Why the field's contents were refused, shown as its supporting text; null when they were not. */ + val inputProblem: MutableState = mutableStateOf(null) - fun validateCredential(): Entity? { - return signInToProfileFormState.textFieldForm.textFieldState.text.toString().let { credentialText -> - if (credentialText.isBlank()) { - signInToProfileFormState.textFieldForm.errorMessage.value = "Please input a credential" - return null - } else { - signInToProfileFormState.textFieldForm.errorMessage.value = null + /** Reads the field and either moves to [SignInToProfileUIState.Confirm] or marks the field. */ + fun recognise() { + when (val result = CredentialParser.parse(signInToProfileFormState.textFieldForm.textFieldState.text.toString())) { + is CredentialParser.Result.Refused -> inputProblem.value = result.problem + is CredentialParser.Result.Recognised -> { + inputProblem.value = null + uiState.value = SignInToProfileUIState.Confirm(result.credential) } - - Nip19Parser.uriToRoute(signInToProfileFormState.textFieldForm.textFieldState.text.toString())?.entity } } - fun signInToAccount( - ) { - logger.d { "signInToAccount" } - validateCredential().let { credentialEntity -> - isActionPending.value = true + /** Back to the field, keeping what was typed. */ + fun useADifferentKey() { + uiState.value = SignInToProfileUIState.InputPrompt + } - viewModelScope.launch(Dispatchers.IO) { - when (credentialEntity) { - is NPub -> { - nostrRepository.signInToProfile( - publicKey = credentialEntity.hex - ) - } - is NSec -> { - nostrRepository.signInToProfile( - publicKey = credentialEntity.toPubKeyHex() - ) - } - else -> { - // TODO: Might want to give reasons for the error. - signInToProfileUIState.value = press.mantra.compose.ui.view.state.SignInToProfileUIState.Error - } + /** The outcome of [commit], for the screen to act on and the tests to assert. */ + sealed interface Outcome { + data class SignedIn(val id: WalletId) : Outcome + data class Failed(val problem: CredentialProblem) : Outcome + } + + /** + * Writes the credential's secret to its store, then plants the placeholder account + * that tells the navigation machine this pubkey has a history to fetch rather than a + * profile to create. Suspends until both are done or one has failed. + */ + suspend fun commit(credential: SignInCredential): Outcome { + val written: Outcome = when (credential) { + is SignInCredential.NostrSecret -> try { + when (val result = writeNostrKey(credential.privateKey)) { + is IdentityWriter.WriteNostrKeyResult.Written -> Outcome.SignedIn(result.id) + is IdentityWriter.WriteNostrKeyResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is IdentityWriter.WriteNostrKeyResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e("could not write the nostr key", e) + Outcome.Failed(CredentialProblem.CouldNotWrite) + } + is SignInCredential.RecoveryPhrase -> { + // Callback-shaped writer, awaited: the seed writer drives WritingSeedState and + // reports through two callbacks, exactly one of which fires. + val outcome = CompletableDeferred() + writeRecoveryPhrase( + credential.words, + { id -> outcome.complete(Outcome.SignedIn(id)) }, + { error -> + outcome.complete( + Outcome.Failed( + when (error) { + is WritingSeedState.Error.SeedAlreadyExists -> CredentialProblem.AlreadyOnThisDevice + is WritingSeedState.Error.CannotLoadSeedMap, + is WritingSeedState.Error.Generic -> CredentialProblem.CouldNotWrite + } + ) + ) + }, + ) + outcome.await() } } + if (written is Outcome.SignedIn) { + // Before the identity is activated -- see the class comment. + nostrRepository.signInToProfile(publicKey = credential.nostrPublicKey) + } + return written } -} \ No newline at end of file + + /** [commit] from the confirm button, moving the screen through Committing to wherever it ends. */ + fun confirm(credential: SignInCredential, onSignedIn: (WalletId) -> Unit) { + if (uiState.value is SignInToProfileUIState.Committing) return + uiState.value = SignInToProfileUIState.Committing + + viewModelScope.launch(Dispatchers.IO) { + when (val outcome = commit(credential)) { + is Outcome.SignedIn -> withContext(Dispatchers.Main) { onSignedIn(outcome.id) } + is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem) + } + } + } + + /** From the error state back to the field. */ + fun retry() { + uiState.value = SignInToProfileUIState.InputPrompt + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 2cf4d2b9..a22d6ba5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -353,6 +353,20 @@ class SovereignWalletViewModel( ) } + /** + * Writes a bare nostr key as a new identity, with the same initialisation options + * [writeSeed] applies. Suspends; the caller is the sign-in view model's coroutine. + */ + suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrKeyResult = + IdentityWriter.writeNostrKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = getGlobalPrefs(), + privateKey = privateKey, + isTorEnabled = isTorEnabled.value, + customElectrumServer = customElectrumServer.value, + ) + companion object { private const val TAG = "SovereignWalletViewModel" fun factory( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt index 3e6bbb74..fb233399 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt @@ -1,20 +1,27 @@ package press.mantra.compose.ui.view.state -import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.SignInCredential -abstract class SignInToProfileUIState { - data object Error: SignInToProfileUIState() +/** + * The sign-in screen's four states. Problems with the *input* are not one of them: those + * stay on the field as supporting text while the prompt is still showing. [Error] is for + * what happens after the user has confirmed -- the write failed, or the key was already + * here -- where there is no field to put the message under. + */ +sealed interface SignInToProfileUIState { + /** The field, waiting for a recovery phrase or an nsec. */ + data object InputPrompt : SignInToProfileUIState - data object InputPrompt: SignInToProfileUIState() + /** + * What was recognised, and the npub it signs as, before anything is written. This is + * the step that catches a paste of the wrong key; for a recovery phrase it is where + * the user learns which profile the words open. The secret itself is never shown. + */ + data class Confirm(val credential: SignInCredential) : SignInToProfileUIState - data class ConfirmNpubSignIn( - val npub: String, - val hexKey: HexKey - ) : SignInToProfileUIState() + /** Writing the secret and planting the account. */ + data object Committing : SignInToProfileUIState - data class ConfirmNsecSignIn( - val nsec: String, - val hexKey: HexKey - ) : SignInToProfileUIState() - -} \ No newline at end of file + data class Error(val problem: CredentialProblem) : SignInToProfileUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt new file mode 100644 index 00000000..e299b4d3 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt @@ -0,0 +1,91 @@ +package press.mantra.compose.identity + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Every row of the sign-in screen's table, in and out. + * + * The accepted rows all use NIP-06's own vector, so the three ways of writing one key -- + * twelve words, an nsec, sixty-four hex characters -- are asserted to land on the same + * public key. That equality is what the duplicate check in `IdentityWriter` rests on. + */ +class CredentialParserJvmTest { + + private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean" + private val nsec = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp" + private val hex = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a" + private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + private val npub = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu" + + private fun recognised(input: String): SignInCredential = + assertIs(CredentialParser.parse(input), "for '$input'").credential + + private fun refused(input: String): CredentialProblem = + assertIs(CredentialParser.parse(input), "for '$input'").problem + + @Test + fun `twelve words are a recovery phrase, and derive the NIP-06 key`() { + val credential = assertIs(recognised(words)) + assertEquals(words.split(" "), credential.words) + assertEquals(publicKey, credential.nostrPublicKey) + } + + @Test + fun `a phrase pasted with line breaks and extra spaces is the same phrase`() { + val credential = assertIs( + recognised(" leader monkey\nparrot ring\tguide accident before fence cannon height naive bean \n") + ) + assertEquals(publicKey, credential.nostrPublicKey) + } + + @Test + fun `an nsec, a nostr-prefixed nsec and the hex all name the same key`() { + for (input in listOf(nsec, "nostr:$nsec", hex, nsec.uppercase(), hex.uppercase())) { + val credential = assertIs(recognised(input), "for '$input'") + assertEquals(publicKey, credential.nostrPublicKey, "for '$input'") + assertEquals(hex, credential.privateKey.value.toHex(), "for '$input'") + } + } + + @Test + fun `nothing is nothing`() { + assertEquals(CredentialProblem.Empty, refused("")) + assertEquals(CredentialProblem.Empty, refused(" \n ")) + } + + @Test + fun `a single word that is not a key is not recognised`() { + assertEquals(CredentialProblem.NotRecognised, refused("hello")) + assertEquals(CredentialProblem.NotRecognised, refused("nostr:")) + assertEquals(CredentialProblem.NotRecognised, refused("7f7ff03d")) // hex, but not 64 of it + } + + @Test + fun `a wrong word, a missing word or a capitalised word is an invalid phrase`() { + assertEquals(CredentialProblem.InvalidPhrase, refused(words.replace("bean", "beans"))) + assertEquals(CredentialProblem.InvalidPhrase, refused(words.substringBeforeLast(" "))) + // The checksum would pass if this were lower-cased; the wordlist is lower-case and + // a capital is a paste artefact the user should see rather than have silently fixed. + assertEquals(CredentialProblem.InvalidPhrase, refused(words.replaceFirstChar { it.uppercase() })) + } + + @Test + fun `a public key is named for what it is`() { + assertEquals(CredentialProblem.PublicKeyOnly, refused(npub)) + assertEquals(CredentialProblem.PublicKeyOnly, refused("nostr:$npub")) + } + + @Test + fun `an encrypted key is named for what it is`() { + assertEquals(CredentialProblem.EncryptedKey, refused("ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p")) + } + + @Test + fun `an nsec that does not decode, or a hex that is not a valid secret, is an invalid key`() { + assertEquals(CredentialProblem.InvalidKey, refused(nsec.dropLast(1) + "q")) + assertEquals(CredentialProblem.InvalidKey, refused("0".repeat(64))) + assertEquals(CredentialProblem.InvalidKey, refused("f".repeat(64))) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt new file mode 100644 index 00000000..cc247437 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt @@ -0,0 +1,119 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.WalletId +import kotlinx.coroutines.runBlocking +import press.mantra.compose.identity.CredentialProblem +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SignInCredential +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * What a confirmed sign-in does, and in what order. + * + * The write and the placeholder are two effects, and the second must follow the first + * and only on success: a placeholder for a key that was not saved would send the user + * to fetch a profile they can never sign for, and a placeholder planted before the + * write is a race the navigation observer can lose. The repository fake records every + * call, so both "exactly once" and "after" are assertable. + */ +class SignInToProfileViewModelJvmTest { + + private val privateKey = PrivateKey(ByteVector32("7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a")) + private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ") + private val id = WalletId("ab".repeat(20)) + + private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey) + private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, publicKey) + + /** Records the order of effects; throws on anything the sign-in should not touch. */ + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun signInToProfile(publicKey: HexKey) { + effects += "signInToProfile:$publicKey" + } + } + + private fun viewModel( + recorder: Recorder, + nostrKeyOutcome: () -> IdentityWriter.WriteNostrKeyResult = { IdentityWriter.WriteNostrKeyResult.Written(id) }, + seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) }, + ) = SignInToProfileViewModel( + nostrRepository = recorder, + writeNostrKey = { recorder.effects += "writeNostrKey"; nostrKeyOutcome() }, + writeRecoveryPhrase = { _, onWritten, onError -> recorder.effects += "writeRecoveryPhrase"; seedOutcome(onWritten, onError) }, + ) + + @Test + fun `an nsec is written, then its account is planted, then the id comes back`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder).commit(nostrSecret) + + assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome) + assertEquals(listOf("writeNostrKey", "signInToProfile:$publicKey"), recorder.effects) + } + + @Test + fun `a recovery phrase goes through the seed writer and plants the same account`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder).commit(recoveryPhrase) + + assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome) + assertEquals(listOf("writeRecoveryPhrase", "signInToProfile:$publicKey"), recorder.effects) + } + + @Test + fun `a key already on the device is refused, and no account is planted`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.AlreadyExists }).commit(nostrSecret) + + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(listOf("writeNostrKey"), recorder.effects) + } + + @Test + fun `a seed already on the device is the same refusal`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.SeedAlreadyExists) }) + .commit(recoveryPhrase) + + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(listOf("writeRecoveryPhrase"), recorder.effects) + } + + @Test + fun `a store that cannot be read, or a write that throws, is could-not-write`() = runBlocking { + val recorder = Recorder() + + assertEquals( + SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), + viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }).commit(nostrSecret), + ) + assertEquals( + SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), + viewModel(recorder, nostrKeyOutcome = { throw IllegalStateException("disk") }).commit(nostrSecret), + ) + assertEquals( + SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), + viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.Generic(IllegalStateException("disk"))) }) + .commit(recoveryPhrase), + ) + assertEquals(emptyList(), recorder.effects.filter { it.startsWith("signInToProfile") }) + } + + /** The credential the parser builds for the phrase and the one it builds for the key agree on the account. */ + @Test + fun `both credentials of one key plant the same account`() { + assertEquals(StoredIdentity.nostrPublicKeyOf(words), nostrSecret.nostrPublicKey) + } +} From 347cf26d83ffdd8d57cbdf80a1e88ac95e69b1b3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 12:16:06 +0200 Subject: [PATCH 17/71] feat(sign-in): ask the relays that would know, and say when none of them did Phase 5 of docs/nsec-sign-in.md. Not nsec-specific: a restored recovery phrase whose profile was never published, or a device offline at sign-in, sat on the same spinner. An imported nsec is the first path that hits it routinely -- many nostr keys are made in a client that never wrote a kind 0. Ask the relays that would know. The sign-in sync fanned its REQ out over Relays.DefaultDMRelayList, which is listOf(ephemeral): our own relay, alone. The right answer for a profile this app created; an identity that has lived on Damus for three years has never heard of it. SignInSync now holds the one definition three call sites want -- the kinds, the request builder and the bootstrap set, which is every indexer relay plus ours. Indexer relays exist to hold everyone's kinds 0, 3 and 10002; that is exactly what the sync asks for. Then follow the answer, once. When a level-0 sign-in request brings back the user's own kind 10002, the sync pump queues the same request at level 1 at the relays that list says they write to, less the bootstrap set already asked. The outbox model doing what it is for: the indexers know *where* the user publishes, and the user's own relays are where the rest of their lists are authoritative. Write relays, not read relays -- asking a user's inbox for their own events is the mistake the split exists to name. One hop per account per session, because every bootstrap relay that holds the list answers with it; and a level-1 request never re-enters, because past the user's own relays lies the feed, not the profile. Say when none of them did. A request went pending -> sent when its REQ was dispatched and sent -> processed only if an event arrived for it; a relay that answered with EOSE and nothing else left its row at `sent` for good, so "still searching" and "searched, found nothing" were the same row and the screen waiting on the sync had no way to say the second thing. The pump's finally block -- every exit: EOSE, CLOSED, the bounded timeout -- now records `complete`, conditionally in SQL on the row still being `sent`, because the event handler that writes `processed` runs in its own coroutine and can land after the subscription has closed, and a completion that overwrote it would turn "found" back into "found nothing". UnsyncedProfileViewModel reads that. Its one decision, `decide`, is a pure function of the account: a kind 0 indexed or a Profile row means the route is about to move, so still searching; any request still pending or sent is a relay that has not finished; only when every request has finished and nothing came is the answer not-found. The screen's not-found state offers two exits. Try again re-queues the bootstrap, and the new in-flight requests put the screen back to searching on their own. Set up a profile is the six-event bootstrap a fresh key gets, without a fresh key: setUpProfileForExistingKey writes the kind 0 *over* the placeholder row, under its own id with signedAt back to null, rather than beside it -- getLocalAccounts is every kind-0 row, and two for one pubkey would be two accounts disagreeing about which is this one. The rows change under observeLocalAccount, NavigationViewModel sees an unsigned kind 0, and the notary, which already holds this key, signs it. createNewProfile now builds its events through the same bootstrapUnsignedEvents. Tests. SignInSyncTest pins the bootstrap set (every indexer, ours, no duplicates, more than one) and the outbox reading of a relay list: write and unmarked relays in, read relays and malformed tags out, already-asked removed. UnsyncedProfileDecisionTest walks pending -> sent -> complete and asserts the answer flips only on the last, that a request answered with something other than a profile still counts as finished, that an arrived kind 0 is never not-found. SynchronizeNostrEventRequestCompletionJvmTest pins the conditional update against a real Room database: sent becomes complete, processed and pending are left alone. SetUpProfileForExistingKeyJvmTest runs signInToProfile then the set-up against the repository and asserts one kind-0 row, the same id, unsigned, carrying the name, with five events beside it. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (895 tests) and :composeApp:m3Audit. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@d61d3560a090c35b8002fa73d0e24b863002e33e --- .../composeResources/values/strings.xml | 5 + .../dao/SynchronizeNostrEventRequestDao.kt | 9 + .../repository/DatabaseNostrRepository.kt | 52 +++- .../press/mantra/compose/nostr/SignInSync.kt | 91 +++++++ .../compose/repository/NostrRepository.kt | 33 +++ .../ui/composable/UnsyncedProfileScreen.kt | 234 ++++++++++++++---- .../ui/composable/navigation/MantraNavHost.kt | 3 +- .../ui/view/model/SynchronizationViewModel.kt | 52 ++++ ...UnqueuedProfileSynchronizationViewModel.kt | 44 +--- .../ui/view/model/UnsyncedProfileViewModel.kt | 141 +++++++++++ .../ui/view/state/UnsyncedProfileUIState.kt | 10 + .../mantra/compose/nostr/SignInSyncTest.kt | 76 ++++++ .../view/model/UnsyncedProfileDecisionTest.kt | 124 ++++++++++ ...onizeNostrEventRequestCompletionJvmTest.kt | 74 ++++++ .../SetUpProfileForExistingKeyJvmTest.kt | 70 ++++++ 15 files changed, 940 insertions(+), 78 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b9b5da9e..da3088e5 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -590,4 +590,9 @@ Twelve words, or nsec1… Use a different key You are signing in as + + Enter a name for the profile. + It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here. + Set up a profile + We could not find a profile for this key on the relays we asked. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt index 8620b38d..39f16323 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt @@ -20,6 +20,15 @@ interface SynchronizeNostrEventRequestDao { @Upsert suspend fun upsert(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) + /** + * Records that the relay has said everything it has, *if* nothing arrived: a request + * an event has already moved to `processed` stays there. Conditional in SQL rather + * than read-then-write because the event handling that writes `processed` runs in + * its own coroutine and can land after the subscription has closed. + */ + @Query("UPDATE SynchronizeNostrEventRequest SET status = :status, updatedAt = :updatedAt WHERE id = :id AND status = 'sent'") + suspend fun markCompleteIfSent(id: String, status: String, updatedAt: kotlin.time.Instant): Int + @Insert suspend fun insert(synchronizeNostrEventRequests: List) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 2e64b2f5..906f0d62 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -124,6 +124,46 @@ class DatabaseNostrRepository( ) { logger.d("createNewProfile: $publicKey") + saveUnsignedNostrEvents(bootstrapUnsignedEvents(publicKey, name, biography)) + + onCompletion.invoke() + } + + override suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) { + logger.d("setUpProfileForExistingKey: $publicKey") + + val events = bootstrapUnsignedEvents(publicKey, name, biography) + val profile = events.first { it.kind == MetadataEvent.KIND } + + // The placeholder row is *replaced*, under its own id, rather than joined by a + // second kind 0: `getLocalAccounts` is every kind-0 row, and two for one pubkey + // would be two accounts disagreeing about which is this one. Its `signedAt` goes + // back to null so the notary signs it, and the sync requests that hang off the id + // stay where they are as the record of the search that found nothing. + database.unsignedNostrEventDao().upsert( + profile.copy( + id = placeholderUnsignedNostrEventId, + signedAt = null, + ) + ) + saveUnsignedNostrEvents(events.filterNot { it.kind == MetadataEvent.KIND }) + } + + /** + * What a key gets when it first becomes a profile here: the kind 0, a contact list + * that follows itself, and the relay lists the app reads, all pointed at the defaults. + * The kind 0 is first. + */ + private fun bootstrapUnsignedEvents( + publicKey: HexKey, + name: String?, + biography: String?, + ): List { val profileEventTemplate = MetadataEvent.createNew( name = name, about = biography, @@ -207,9 +247,7 @@ class DatabaseNostrRepository( ) ) - saveUnsignedNostrEvents(unsignedNostrEvents) - - onCompletion.invoke() + return unsignedNostrEvents } override suspend fun signInToProfile( @@ -495,6 +533,14 @@ class DatabaseNostrRepository( ) } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: press.mantra.compose.database.model.SynchronizeNostrEventRequest) { + database.synchronizeNostrEventRequestDao().markCompleteIfSent( + id = synchronizeNostrEventRequest.id, + status = press.mantra.compose.nostr.SignInSync.STATUS_COMPLETE, + updatedAt = Clock.System.now(), + ) + } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: press.mantra.compose.database.model.NegentropySynchronizeRequest) { database.negentropySynchronizeRequestDao().upsert( negentropySynchronizeRequest.copy( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt new file mode 100644 index 00000000..93e62645 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/SignInSync.kt @@ -0,0 +1,91 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * The sync that finds an existing identity's profile: what it asks for, whom it asks, + * and the one hop it follows. + * + * Three call sites want the same request -- the screen that first queues it, the + * "try again" on the screen that waits for it, and the hop below -- so it is built here + * once. See docs/nsec-sign-in.md, Phase 5. + */ +object SignInSync { + const val PURPOSE = "sign-in" + + /** A request has left the queue but no relay has finished answering it. */ + val IN_FLIGHT_STATUSES = setOf("pending", "sent") + + /** Set once a relay has said everything it has -- EOSE, CLOSED or the bounded timeout. */ + const val STATUS_COMPLETE = "complete" + + /** The profile, the contacts, and every relay list the app reads. */ + val KINDS = arrayOf( + MetadataEvent.KIND, + ContactListEvent.KIND, + AdvertisedRelayListEvent.KIND, + ChatMessageRelayListEvent.KIND, + SearchRelayListEvent.KIND, + IndexerRelayListEvent.KIND, + ChannelListEvent.KIND, + RelayFeedsListEvent.KIND, + ) + + /** + * Whom to ask first. Indexer relays exist to hold everyone's kinds 0, 3 and 10002 -- + * that is the whole of their purpose, and it is exactly the list above -- so they are + * where an identity that has lived elsewhere for years will be found. Our own relay + * stays in the set so a profile created here is found here. This used to be our relay + * alone, which is the right answer only for a profile this app made. + */ + val bootstrapRelays: List = + (Relays.DefaultIndexerRelayList + Relays.ephemeral).distinct() + + fun requests( + unsignedNostrEventId: Long, + publicKey: HexKey, + relays: Collection, + level: Int, + ): List = relays.map { relay -> + SynchronizeNostrEventRequest( + purpose = PURPOSE, + synchronizationFilters = arrayOf( + SynchronizationFilter( + authors = arrayOf(publicKey), + kinds = KINDS, + ) + ), + unsignedNostrEventId = unsignedNostrEventId, + relayURL = relay.url, + level = level, + ) + } + + /** + * The relays a kind 10002 says its author writes to -- `r` tags with no marker or a + * `write` marker -- less the ones already asked. This is the outbox model doing what + * it is for: the indexers know *where* the user publishes, and the user's own relays + * are where the rest of their lists are authoritative. Bounded at one hop by the + * caller, which only follows from a level-0 request; anything past that is the feed, + * not the profile. + */ + fun writeRelaysOf(tags: Array>, alreadyAsked: Collection): List = + tags.asSequence() + .filter { it.size >= 2 && it[0] == "r" && (it.getOrNull(2).isNullOrBlank() || it[2] == "write") } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it[1]) } + .distinct() + .filterNot { it in alreadyAsked } + .toList() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 5372e220..aaf5147a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -60,6 +60,19 @@ interface NostrRepository { publicKey: HexKey ) + /** + * The exit from a sign-in whose search found nothing: the key already exists, only + * the events do not. Gives [publicKey] the same bootstrap [createNewProfile] gives a + * fresh key, with the kind 0 written *over* the placeholder [signInToProfile] planted + * -- see the implementation for why over rather than beside. + */ + suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) + suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -93,6 +106,13 @@ interface NostrRepository { suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) + /** + * The relay has finished answering [synchronizeNostrEventRequest] -- EOSE, CLOSED or + * the bounded timeout -- and the request is still `sent`, meaning nothing arrived. Until + * this existed "still searching" and "searched, found nothing" were the same row. + */ + suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) + suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) @@ -231,6 +251,15 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun setUpProfileForExistingKey( + publicKey: HexKey, + placeholderUnsignedNostrEventId: Long, + name: String?, + biography: String?, + ) { + TODO("Not yet implemented") + } + override suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -278,6 +307,10 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { + TODO("Not yet implemented") + } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 0fa869a7..7a86f4be 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -5,73 +5,222 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import press.mantra.compose.ui.theme.spacing +import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res -import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.enter_a_name_for_the_profile +import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2 +import mantra.composeapp.generated.resources.introduce_yourself +import mantra.composeapp.generated.resources.it_may_be_new_or_it_may_live_on_relays_we +import mantra.composeapp.generated.resources.name_eg_alan_turing +import mantra.composeapp.generated.resources.set_up_a_profile +import mantra.composeapp.generated.resources.try_again import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your -import androidx.compose.material3.SnackbarHost +import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key +import mantra.composeapp.generated.resources.what_should_people_know_about_you +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.UnsyncedProfileViewModel +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +/** + * Waiting for a sign-in sync -- and, since Phase 5 of docs/nsec-sign-in.md, able to say + * when the wait is over with nothing to show for it. Before that the spinner below was + * the whole screen and had no exit: a key made in a client that never wrote a kind 0 + * sat under "searching" for good. + */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnsyncedProfileScreen( - unsyncedProfilePublicKey: String + unsyncedProfilePublicKey: String, + nostrRepository: NostrRepository, ) { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - .readableContent() - ) { + val viewModel: UnsyncedProfileViewModel = viewModel( + factory = UnsyncedProfileViewModel.factory( + publicKey = unsyncedProfilePublicKey, + nostrRepository = nostrRepository, + ) + ) + val uiState by viewModel.uiState.collectAsState() - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space125 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - press.mantra.compose.ui.composable.widgets.LoadingDataIndicator( - fillScreen = false - ) - - Spacer( - modifier = Modifier.weight(2f) + // imePadding for the not-found form's fields. + Scaffold( + modifier = Modifier.imePadding(), + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + ) { innerPadding -> + ScreenStateTransition( + state = uiState, + modifier = Modifier.fillMaxSize().padding(innerPadding), + label = "unsynced profile", + ) { state -> + when (state) { + is UnsyncedProfileUIState.Searching -> Searching() + is UnsyncedProfileUIState.NotFound -> NotFound( + viewModel = viewModel, + unsignedNostrEventId = state.unsignedNostrEventId, ) } } } } +@Composable +private fun Searching() { + Column( + modifier = Modifier + .fillMaxSize() + .readableContent() + ) { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space125 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } +} + +/** + * Every relay asked has answered and none had a profile. Two ways out: ask again, or + * give the key that is already on this device a profile -- the six-event bootstrap a + * fresh key gets, without a fresh key. + */ +@Composable +private fun NotFound( + viewModel: UnsyncedProfileViewModel, + unsignedNostrEventId: Long, +) { + val nameField = viewModel.formState.nameField.textFieldState + val biographyField = viewModel.formState.biographyField.textFieldState + val nameMissing = viewModel.nameMissing.value + val isActionPending = viewModel.isActionPending.value + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .readableContent() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + EmptyState( + message = stringResource(Res.string.we_could_not_find_a_profile_for_this_key), + icon = Icons.Default.PersonSearch, + action = { + TextButton( + enabled = !isActionPending, + onClick = { viewModel.tryAgain(unsignedNostrEventId) }, + ) { + Text(stringResource(Res.string.try_again)) + } + }, + ) + + Text( + text = stringResource(Res.string.it_may_be_new_or_it_may_live_on_relays_we), + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = stringResource(Res.string.set_up_a_profile), + style = MaterialTheme.typography.titleMedium, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = nameField, + isError = nameMissing, + supportingText = if (nameMissing) { + { Text(text = stringResource(Res.string.enter_a_name_for_the_profile)) } + } else null, + lineLimits = TextFieldLineLimits.SingleLine, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + label = { Text(text = stringResource(Res.string.name_eg_alan_turing), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.enter_the_name_you_want_to_use_for_your_2), maxLines = 1) }, + // The label names the field; the icon only decorates it. + leadingIcon = { Icon(Icons.Default.Badge, contentDescription = Decorative) }, + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + state = biographyField, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + label = { Text(text = stringResource(Res.string.introduce_yourself), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.what_should_people_know_about_you), maxLines = 1) }, + leadingIcon = { Icon(Icons.Default.Description, contentDescription = Decorative) }, + ) + + Button( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = { viewModel.setUpProfile(unsignedNostrEventId) }, + ) { + Text(text = stringResource(Res.string.set_up_a_profile)) + } + } +} + @ConformancePreviews @Composable private fun UnsyncedProfileScreenPreview() { @@ -80,8 +229,9 @@ private fun UnsyncedProfileScreenPreview() { modifier = Modifier.fillMaxSize() ) { UnsyncedProfileScreen( - unsyncedProfilePublicKey = "npub is for living." + unsyncedProfilePublicKey = "a".repeat(64), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 976bc510..7048f9df 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -701,7 +701,8 @@ fun MantraNavHost( composable { backStackEntry -> val route = backStackEntry.toRoute() UnsyncedProfileScreen( - unsyncedProfilePublicKey = route.publicKey + unsyncedProfilePublicKey = route.publicKey, + nostrRepository = databaseNostrRepository, ) } composable { backStackEntry -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index eba82d06..cd4428a0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -50,9 +50,11 @@ import kotlinx.coroutines.flow.transformWhile import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.NonCancellable +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import press.mantra.compose.nostr.SignInSync import kotlinx.coroutines.sync.withPermit import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -278,6 +280,7 @@ class SynchronizationViewModel( activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) + followSignInHop(it, synchronizeNostrEventRequest) } } } @@ -292,6 +295,7 @@ class SynchronizationViewModel( activeKeyPair = keyPair // TODO: + Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url } ) + followSignInHop(nostrEvent, synchronizeNostrEventRequest) } } is NostrIncomingMessage.EoseMessage -> { @@ -329,6 +333,16 @@ class SynchronizationViewModel( synchronizeNostrEventRequest.relayURL ) } + // And write down that the relay has finished, on every exit + // too. Before this a request that got EOSE with nothing stayed + // at `sent` for good, and "still searching" and "searched, + // found nothing" were the same row -- which is why the screen + // waiting on a sign-in sync could never say the second thing. + // Conditional on `sent` in the DAO: an event handler that has + // already moved the row to `processed` wins. + runCatching { + nostrRepository.synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest) + }.onFailure { logger.e("Failed to record sync completion", it) } } } } @@ -338,6 +352,44 @@ class SynchronizationViewModel( } } + /** + * Sign-in syncs this session has already followed a hop for, by account. Every + * bootstrap relay that holds the user's kind 10002 answers with it, and one hop per + * account is enough. + */ + private val signInHopsFollowed = mutableSetOf() + private val signInHopsMutex = Mutex() + + /** + * The one hop the sign-in sync follows. A level-0 request that brings back the user's + * own kind 10002 is asked again, at level 1, at the relays that list says they write + * to, less the bootstrap set already asked. That is the outbox model doing what it is + * for: the indexers know *where* the user publishes, and the user's own relays are + * where the rest of their lists are authoritative. One hop only -- a level-1 request + * never re-enters here -- because past the user's own relays lies the feed, not the + * profile. + */ + private suspend fun followSignInHop( + nostrEvent: press.mantra.compose.database.model.NostrEvent, + request: press.mantra.compose.database.model.SynchronizeNostrEventRequest, + ) { + if (request.purpose != SignInSync.PURPOSE || request.level != 0) return + if (nostrEvent.kind != AdvertisedRelayListEvent.KIND) return + val unsignedNostrEventId = request.unsignedNostrEventId ?: return + val author = request.synchronizationFilters.firstOrNull()?.authors?.firstOrNull() ?: return + if (!nostrEvent.pubKey.equals(author, ignoreCase = true)) return + + val relays = SignInSync.writeRelaysOf(nostrEvent.tags, alreadyAsked = SignInSync.bootstrapRelays) + if (relays.isEmpty()) return + val isFirst = signInHopsMutex.withLock { signInHopsFollowed.add(unsignedNostrEventId) } + if (!isFirst) return + + logger.i { "Following the sign-in relay list of $author to ${relays.size} relay(s)" } + nostrRepository.queueSynchronizeNostrEvent( + SignInSync.requests(unsignedNostrEventId, author, relays, level = 1) + ) + } + private suspend fun observePendingNegentropySynchronizeRequests( keyPair: KeyPair ): Unit = coroutineScope { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt index 37d95631..e648c10a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt @@ -8,18 +8,11 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import press.mantra.compose.nostr.SignInSync class UnqueuedProfileSynchronizationViewModel( val unsignedNostrEventId: Long, @@ -48,6 +41,11 @@ class UnqueuedProfileSynchronizationViewModel( val isActionPending: MutableState = mutableStateOf(false) + /** + * Asks the bootstrap relays -- the indexers, and ours -- for everything this pubkey + * has published about itself. The relay list that comes back is followed one hop by + * the sync pump; see [SignInSync]. + */ fun queueSynchronization() { logger.d { "queueSynchronization" } viewModelScope.launch(Dispatchers.IO) { @@ -55,30 +53,12 @@ class UnqueuedProfileSynchronizationViewModel( delay(2_000) nostrRepository.queueSynchronizeNostrEvent( - press.mantra.compose.nostr.Relays.DefaultDMRelayList.map { normalizedRelayUrl -> - press.mantra.compose.database.model.SynchronizeNostrEventRequest( - purpose = "sign-in", - synchronizationFilters = arrayOf( - press.mantra.compose.database.model.types.SynchronizationFilter( - authors = arrayOf(profilePublicKey), - kinds = arrayOf( - MetadataEvent.KIND, - ContactListEvent.KIND, - AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, - SearchRelayListEvent.KIND, - IndexerRelayListEvent.KIND, - ChannelListEvent.KIND, - RelayFeedsListEvent.KIND - ) - ) - ), - unsignedNostrEventId = unsignedNostrEventId, - relayURL = normalizedRelayUrl.url, - level = 0 - ) - } - + SignInSync.requests( + unsignedNostrEventId = unsignedNostrEventId, + publicKey = profilePublicKey, + relays = SignInSync.bootstrapRelays, + level = 0, + ) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt new file mode 100644 index 00000000..c5f041e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileViewModel.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.nostr.SignInSync +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +import press.mantra.compose.ui.view.state.form.CreateProfileFormState + +/** + * The screen that waits for a sign-in sync, and the one decision it makes: whether the + * search is still running or has finished with nothing. + * + * `NavigationViewModel` keeps the route at `UnsyncedProfile` for as long as the account + * has sync requests and no kind 0; it cannot tell the two apart, and until Phase 5 of + * docs/nsec-sign-in.md nothing could. Now every request records when its relay has + * finished answering, and [decide] reads that: no request still in flight, and still + * no profile, is not-found. The two exits from not-found are to ask again and to set a + * profile up for the key that is already here. + */ +class UnsyncedProfileViewModel( + private val publicKey: HexKey, + private val nostrRepository: NostrRepository, + val formState: CreateProfileFormState = CreateProfileFormState(), +) : ViewModel() { + + companion object { + private const val TAG = "UnsyncedProfileViewModel" + + fun factory( + publicKey: HexKey, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + UnsyncedProfileViewModel( + publicKey = publicKey, + nostrRepository = nostrRepository, + ) + } + } + + /** + * What the account says. A kind 0 indexed or a Profile row means the navigation + * machine is about to move on, so that is still "searching" from here; a request + * still `pending` or `sent` is a relay that has not finished. Only when every + * request has finished and nothing came is the answer not-found. + */ + fun decide(account: LocalAccount?): UnsyncedProfileUIState { + val unsignedNostrEvent = account?.unsignedNostrEvent ?: return UnsyncedProfileUIState.Searching + if (account.profile != null || account.nostrEvent != null) return UnsyncedProfileUIState.Searching + val requests = account.synchronizeNostrEventRequests + if (requests.isEmpty()) return UnsyncedProfileUIState.Searching + if (requests.any { it.status in SignInSync.IN_FLIGHT_STATUSES }) return UnsyncedProfileUIState.Searching + return UnsyncedProfileUIState.NotFound(unsignedNostrEventId = unsignedNostrEvent.id) + } + } + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** True after a submit with no name; the field shows why. */ + val nameMissing: MutableState = mutableStateOf(false) + + val uiState: StateFlow = accountFlow() + .map { decide(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UnsyncedProfileUIState.Searching) + + private fun accountFlow(): Flow = flow { + nostrRepository.observeLocalAccount(publicKey).collect { emit(it) } + } + + /** Asks the bootstrap relays again. The new requests are in flight, so the screen goes back to searching on its own. */ + fun tryAgain(unsignedNostrEventId: Long) { + if (isActionPending.value) return + isActionPending.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.queueSynchronizeNostrEvent( + SignInSync.requests( + unsignedNostrEventId = unsignedNostrEventId, + publicKey = publicKey, + relays = SignInSync.bootstrapRelays, + level = 0, + ) + ) + } catch (e: Exception) { + logger.e("could not queue the sign-in sync again", e) + } finally { + isActionPending.value = false + } + } + } + + /** + * Gives the key a profile. The rows change under `observeLocalAccount`, the + * navigation machine sees an unsigned kind 0 and routes to the create flow's own + * screens, and the notary -- which already holds this key -- signs it. + */ + fun setUpProfile(unsignedNostrEventId: Long) { + if (isActionPending.value) return + val name = formState.nameField.textFieldState.text.toString().trim() + if (name.isBlank()) { + nameMissing.value = true + return + } + nameMissing.value = false + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.setUpProfileForExistingKey( + publicKey = publicKey, + placeholderUnsignedNostrEventId = unsignedNostrEventId, + name = name, + biography = formState.biographyField.textFieldState.text.toString().trim().ifBlank { null }, + ) + } catch (e: Exception) { + logger.e("could not set up a profile for the key", e) + isActionPending.value = false + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt new file mode 100644 index 00000000..1c908cf1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/UnsyncedProfileUIState.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.view.state + +/** What the screen waiting on a sign-in sync is showing. */ +sealed interface UnsyncedProfileUIState { + /** At least one relay has not finished answering, or something has arrived and the route is about to move. */ + data object Searching : UnsyncedProfileUIState + + /** Every relay asked has finished, and none had a profile for this key. */ + data class NotFound(val unsignedNostrEventId: Long) : UnsyncedProfileUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt new file mode 100644 index 00000000..9db84af3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/SignInSyncTest.kt @@ -0,0 +1,76 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Whom the sign-in sync asks, and how it reads a relay list to ask further. + * + * The bootstrap set is the load-bearing change of Phase 5: it used to be one relay, ours, + * which is the right answer only for a profile this app made. The hop test pins the + * outbox reading -- write relays, not read relays -- because asking a user's *read* + * relays for their own events is the mistake the inbox/outbox split exists to name. + */ +class SignInSyncTest { + + private val publicKey = "a".repeat(64) + private fun relay(url: String) = RelayUrlNormalizer.normalize(url) + + @Test + fun `the bootstrap asks every indexer and our own relay, once each`() { + val bootstrap = SignInSync.bootstrapRelays + + assertTrue(bootstrap.containsAll(Relays.DefaultIndexerRelayList), "the indexers hold everyone's kinds 0, 3 and 10002") + assertTrue(Relays.ephemeral in bootstrap, "a profile created here is found here") + assertEquals(bootstrap.size, bootstrap.toSet().size, "no relay asked twice") + assertTrue(bootstrap.size > 1, "one relay was the whole problem") + } + + @Test + fun `one request per relay, all asking for the same things about the same key`() { + val relays = listOf(relay("wss://one.example"), relay("wss://two.example")) + + val requests = SignInSync.requests(unsignedNostrEventId = 7, publicKey = publicKey, relays = relays, level = 1) + + assertEquals(relays.map { it.url }, requests.map { it.relayURL }) + requests.forEach { request -> + assertEquals(SignInSync.PURPOSE, request.purpose) + assertEquals(1, request.level) + assertEquals(7L, request.unsignedNostrEventId) + assertEquals("pending", request.status) + val filter = request.synchronizationFilters.single() + assertEquals(listOf(publicKey), filter.authors?.toList()) + assertEquals(SignInSync.KINDS.toList(), filter.kinds?.toList()) + } + } + + @Test + fun `a relay list yields its write relays, less those already asked`() { + val tags = arrayOf( + arrayOf("r", "wss://write.example", "write"), + arrayOf("r", "wss://both.example"), + arrayOf("r", "wss://read.example", "read"), + arrayOf("r", "wss://asked.example"), + arrayOf("r", "wss://both.example"), // duplicate + arrayOf("r", "not a url"), // dropped by the normaliser + arrayOf("p", "wss://tagged-wrong.example"), // not an r tag + arrayOf("r"), // no url + ) + + val relays = SignInSync.writeRelaysOf(tags, alreadyAsked = listOf(relay("wss://asked.example"))) + + assertEquals( + listOf(relay("wss://write.example"), relay("wss://both.example")), + relays, + ) + } + + @Test + fun `a relay list with only read relays yields nothing to follow`() { + val tags = arrayOf(arrayOf("r", "wss://read.example", "read")) + + assertEquals(emptyList(), SignInSync.writeRelaysOf(tags, alreadyAsked = emptyList())) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt new file mode 100644 index 00000000..c543e8f4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/model/UnsyncedProfileDecisionTest.kt @@ -0,0 +1,124 @@ +package press.mantra.compose.ui.view.model + +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.SignInSync +import press.mantra.compose.ui.view.state.UnsyncedProfileUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * When "searching" becomes "not found". + * + * A request is `pending` when queued, `sent` when its REQ is out, `processed` if an + * event came back for it, and -- since Phase 5 -- `complete` if its relay finished with + * nothing. Only the first two mean a relay has not answered yet, and only when none is + * in either state, and still no kind 0 has arrived, may the screen say the search is + * over. Anything else is the wrong message at the wrong time: too early and the user + * sets up a second profile while the first is on its way; never and they sit under a + * spinner for good, which is what the screen did before. + */ +class UnsyncedProfileDecisionTest { + + private val publicKey = "a".repeat(64) + private val at = Instant.fromEpochSeconds(1_700_000_000) + + private val placeholder = UnsignedNostrEvent( + id = 1, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + signedAt = GENESIS_AT, + createdAt = at, + updatedAt = at, + savedAt = at, + ) + + private fun request(status: String, relay: String) = SynchronizeNostrEventRequest( + id = "req-$relay", + purpose = SignInSync.PURPOSE, + status = status, + relayURL = "wss://$relay.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf(publicKey), kinds = SignInSync.KINDS)), + unsignedNostrEventId = 1, + createdAt = at, + updatedAt = at, + ) + + private fun account( + vararg requests: SynchronizeNostrEventRequest, + profile: Profile? = null, + nostrEvent: NostrEvent? = null, + ) = LocalAccount( + unsignedNostrEvent = placeholder, + nostrEvent = nostrEvent, + profile = profile, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = requests.toList(), + ) + + @Test + fun `the answer flips only when the last relay has finished`() { + val sequence = listOf( + account(request("pending", "one"), request("pending", "two")), + account(request("sent", "one"), request("pending", "two")), + account(request("sent", "one"), request("sent", "two")), + account(request("complete", "one"), request("sent", "two")), + account(request("complete", "one"), request("complete", "two")), + ) + + val decisions = sequence.map { UnsyncedProfileViewModel.decide(it) } + + assertEquals( + listOf( + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.Searching, + UnsyncedProfileUIState.NotFound(unsignedNostrEventId = 1), + ), + decisions, + ) + } + + /** An event came back for a request -- a contact list, say -- but still no kind 0: the search is still over. */ + @Test + fun `a relay that answered with something other than a profile still counts as finished`() { + val decision = UnsyncedProfileViewModel.decide(account(request("processed", "one"), request("complete", "two"))) + + assertEquals(UnsyncedProfileUIState.NotFound(unsignedNostrEventId = 1), decision) + } + + @Test + fun `a kind 0 that has arrived, indexed or not, is never not-found`() { + val event = NostrEvent( + id = "e".repeat(64), + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + createdAt = at, + sig = "f".repeat(128), + unsignedNostrEventId = 1, + ) + val profile = Profile(publicKey = publicKey, nostrEventId = event.id, createdAt = at, updatedAt = at, savedAt = at) + + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account(request("complete", "one"), nostrEvent = event))) + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account(request("complete", "one"), profile = profile))) + } + + @Test + fun `no account, or no request yet, is still searching`() { + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(null)) + assertEquals(UnsyncedProfileUIState.Searching, UnsyncedProfileViewModel.decide(account())) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt new file mode 100644 index 00000000..04e60efa --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestCompletionJvmTest.kt @@ -0,0 +1,74 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.SignInSync +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * `markCompleteIfSent` is conditional in SQL for a reason worth pinning: the handler + * that moves a request to `processed` when an event arrives runs in its own coroutine + * and can land after the subscription has closed. A completion that overwrote it would + * turn "found" back into "found nothing". + */ +class SynchronizeNostrEventRequestCompletionJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val at = Instant.fromEpochSeconds(1_700_000_000) + private val later = Instant.fromEpochSeconds(1_700_000_100) + + private suspend fun placeholder(): Long = db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = "a".repeat(64), kind = 0, tags = emptyArray(), content = "{}", createdAt = at) + ) + + private fun request(id: String, status: String, unsignedNostrEventId: Long) = SynchronizeNostrEventRequest( + id = id, + purpose = SignInSync.PURPOSE, + status = status, + relayURL = "wss://$id.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf("a".repeat(64)), kinds = SignInSync.KINDS)), + unsignedNostrEventId = unsignedNostrEventId, + createdAt = at, + updatedAt = at, + ) + + private suspend fun statusOf(unsignedNostrEventId: Long, id: String): String? = + db.unsignedNostrEventDao().observeProfile("a".repeat(64)).first() + ?.synchronizeNostrEventRequests?.firstOrNull { it.id == id }?.status + + @Test + fun `a sent request becomes complete, a processed or pending one is left alone`() = runBlocking { + val accountId = placeholder() + val dao = db.synchronizeNostrEventRequestDao() + dao.insert( + listOf( + request("sent", "sent", accountId), + request("processed", "processed", accountId), + request("pending", "pending", accountId), + ) + ) + + val changed = listOf("sent", "processed", "pending").sumOf { dao.markCompleteIfSent(it, SignInSync.STATUS_COMPLETE, later) } + + assertEquals(1, changed) + assertEquals(SignInSync.STATUS_COMPLETE, statusOf(accountId, "sent")) + assertEquals("processed", statusOf(accountId, "processed")) + assertEquals("pending", statusOf(accountId, "pending")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt new file mode 100644 index 00000000..7aff683c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SetUpProfileForExistingKeyJvmTest.kt @@ -0,0 +1,70 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The exit from a sign-in that found nothing, at the rows. + * + * `signInToProfile` plants one kind-0 row per signed-in key, stamped `GENESIS_AT`, and + * `getLocalAccounts` is *every* kind-0 row. So giving the key a profile must write over + * that row, not beside it: two kind-0 rows for one pubkey would be two accounts, and + * `observeLocalAccount` would answer with whichever the query happened to return. + */ +class SetUpProfileForExistingKeyJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val publicKey = "a".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `the placeholder is written over, once, and the rest of the bootstrap arrives beside it`() = runBlocking { + repository.signInToProfile(publicKey) + val placeholder = repository.getLocalAccounts().single().unsignedNostrEvent + assertNotNull(placeholder) + assertEquals(GENESIS_AT, placeholder.signedAt) + + repository.setUpProfileForExistingKey( + publicKey = publicKey, + placeholderUnsignedNostrEventId = placeholder.id, + name = "Alan", + biography = null, + ) + + val accounts = repository.getLocalAccounts() + assertEquals(1, accounts.size, "one kind-0 row for the key, still") + val profile = accounts.single().unsignedNostrEvent + assertNotNull(profile) + assertEquals(placeholder.id, profile.id, "the same row") + assertNull(profile.signedAt, "unsigned again, so the notary signs it") + assertTrue(profile.content.contains("Alan"), "carrying the profile the user typed") + + // The five other bootstrap events, queued for the notary alongside it. + val queued = db.unsignedNostrEventDao().observeUnsignedNostrEvents(publicKey).first() + assertEquals(6, queued.size) + assertEquals(listOf(0), queued.filter { it.kind == 0 }.map { it.kind }) + } +} From e2295b39715ee2c07a05ad965bd8be24e98be3e7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 12:27:54 +0200 Subject: [PATCH 18/71] feat(recovery): the key behind an identity that has no phrase, and the way to forget it Phase 6 of docs/nsec-sign-in.md. KeyRecoveryScreen offered one backup, the recovery phrase, whose screen reads userWallet.words out of the seed map. For an identity signed in with a bare key there are no words, and the honest answer that screen would give is "this device holds no phrase for the profile". So: KeyRecoveryScreen branches on the active identity's kind. A mnemonic identity keeps the phrase option. A NostrSecret identity gets a nostr secret key option in its place, routing to NostrSecretRoute, with its own status line ("you said you stored it") and a header that no longer promises coins. The backup flags underneath are the same two per-identity preferences the phrase screen writes; they already mean "this identity's secret is not backed up" for whichever secret it is. NostrSecretScreen is RecoveryPhraseScreen with the word grid replaced by the nsec: hidden until revealed, revealed in monospace, selectable, with a copy action -- nobody transcribes sixty-three characters by hand -- the same two checkboxes, hidden again on leaving. The view model reads the key from nostr-keys.dat at reveal time, by the identity's public key, not off the active identity, so the screen's contract -- nothing secret held longer than it is shown -- is the phrase screen's. The disclaimer is a new string: the old one said "...and the funds in its wallet", and this identity has no wallet to warn about. Forget this key. An import needs an inverse, and this is the first real "sign out" in the app; ActiveProfileScreen's button still routes to a pending screen, and the general case stays there, because removing a seed is a wallet question with funds behind it. Behind a dialog that names the npub and says the profile stays on the relays, four effects in an order that matters: the key out of nostr-keys.dat and the identity's preference files off the disk (IdentityWriter.forgetNostrKey, which refuses a key that is not in the file -- a wallet's nostr key lives in the seed); the metadata entry hidden, since the metadata store has no delete and isHidden is what the selector filters on; the account's unsigned rows deleted (forgetLocalAccount -- the kind 0 that made it a local account and anything queued that can now never be signed, with the requests that hang off them cascading; published events and the profile cache stay, as anyone else's would); and only then the caller told, which re-lists identities and clears the active one so the navigation observer sends a null identity to startup. A failure at the first step leaves the rest untouched -- an identity the selector lists but nothing can open is worse than one that is still there. Tests. IdentityWriterJvmTest runs both bare-key writers against the jvm key store and a real directory: a key written once and refused the second time by public key, with its preferences file created; forgetting one key leaves the other and takes its preferences with it; a key that is not in the file is not forgotten. Its keys are fresh per test rather than fixed, because DataStoreManager caches each id's preferences in a companion object for the life of the process, and a fixed key was served the UserPrefs an earlier test had created in an earlier temp directory -- worth knowing about that cache. NostrSecretViewModelJvmTest records the four effects and asserts their order, and that a key that could not be removed stops the sequence at one. ForgetLocalAccountJvmTest, against Room, asserts the account and its cascaded requests go while an unrelated account stays. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (901 tests) and :composeApp:m3Audit. Replayed onto Mantra by docs/curated-to-mantra.md: KeyRecoveryScreen.kt: the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@e3cc23ae319b0ef6a7fb7a25c53a2f15d44cf720 --- .../composeResources/values/strings.xml | 24 + .../database/dao/UnsignedNostrEventDao.kt | 9 + .../repository/DatabaseNostrRepository.kt | 5 + .../mantra/compose/identity/IdentityWriter.kt | 38 ++ .../compose/repository/NostrRepository.kt | 11 + .../ui/composable/KeyRecoveryScreen.kt | 94 +++- .../ui/composable/NostrSecretScreen.kt | 490 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 23 + .../navigation/routes/NostrSecretRoute.kt | 7 + .../ui/view/model/NostrSecretViewModel.kt | 213 ++++++++ .../ui/view/model/SovereignWalletViewModel.kt | 24 + .../ui/view/state/NostrSecretUIState.kt | 35 ++ .../repository/ForgetLocalAccountJvmTest.kt | 56 ++ .../compose/identity/IdentityWriterJvmTest.kt | 122 +++++ .../view/model/NostrSecretViewModelJvmTest.kt | 120 +++++ 15 files changed, 1244 insertions(+), 27 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index da3088e5..1d34050d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -595,4 +595,28 @@ It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here. Set up a profile We could not find a profile for this key on the relays we asked. + + Copied the nsec + Copy nsec + Could not forget the key. Please try again. + Could not unlock your key. Please try again. + Mantra will delete the nsec for %1$s from this device. Anything not yet published is lost. The profile stays on the relays, and the key can be signed in again. + Display nostr secret key + Forget + Forget this key + Forget this key? + I have saved my nostr secret key somewhere safe. + I understand that if I lose this phone and my nostr secret key, I lose this profile. + Keep this key safe.\nDo not share it. + Key forgotten + No profile is open on this device, so there is no key to show. + Nostr secret key + Remove the key from this device. The profile stays on the relays. + Store the nsec that signs as this profile. There is no phrase behind it: the key is the whole backup. + The nsec is the key that signs as you. This profile was signed in with it rather than made from a recovery phrase, so there is no phrase to write down and no wallet attached: the key is the whole of the backup.\n\nOnly you have it. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Store it somewhere safe that is not this phone. If you lose both the phone and the key, this profile is gone for good. + This device holds no nostr secret key for the profile that is open. If it was made from a recovery phrase, the phrase is its backup. + Unlocking your key… + You have not backed up your nostr secret key + These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone. + You said you stored it diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt index a6894d90..ba1e6cfe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/UnsignedNostrEventDao.kt @@ -23,6 +23,15 @@ interface UnsignedNostrEventDao { @Upsert suspend fun upsert(unsignedNostrEvent: UnsignedNostrEvent): Long + /** + * Every unsigned row of a key this device no longer holds: the kind 0 that makes it a + * local account, and anything queued that can now never be signed. The requests that + * hang off them cascade; published events and the profile cache do not, and stay as + * they would for anyone else's key. + */ + @Query("DELETE FROM UnsignedNostrEvent WHERE pubKey = :publicKey") + suspend fun deleteAllForPublicKey(publicKey: String): Int + @Insert suspend fun insert(unsignedNostrEvents: List) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 906f0d62..baa14952 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -154,6 +154,11 @@ class DatabaseNostrRepository( saveUnsignedNostrEvents(events.filterNot { it.kind == MetadataEvent.KIND }) } + override suspend fun forgetLocalAccount(publicKey: HexKey) { + logger.d("forgetLocalAccount: $publicKey") + database.unsignedNostrEventDao().deleteAllForPublicKey(publicKey) + } + /** * What a key gets when it first becomes a profile here: the kind 0, a contact list * that follows itself, and the relay lists the app reads, all pointed at the defaults. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt index a6b2a484..40ff06dc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -206,4 +206,42 @@ object IdentityWriter { return WriteNostrKeyResult.Written(stored.id) } + + sealed class ForgetNostrKeyResult { + data object Forgotten : ForgetNostrKeyResult() + + /** The key is not in `nostr-keys.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */ + data object NotABareKey : ForgetNostrKeyResult() + + data object CannotLoadKeys : ForgetNostrKeyResult() + } + + /** + * The inverse of [writeNostrKey]: removes the key from `nostr-keys.dat` and deletes + * the identity's preference files. The profile stays on the relays, and the same key + * can be signed in again. Only for a bare key -- see [ForgetNostrKeyResult.NotABareKey]. + */ + suspend fun forgetNostrKey( + log: Logger, + phoenixGlobal: PhoenixGlobal, + id: WalletId, + nostrPublicKey: HexKey, + ): ForgetNostrKeyResult { + val existing = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + if (existing == null) { + log.e("could not load the existing keys, aborting...") + return ForgetNostrKeyResult.CannotLoadKeys + } + if (!existing.containsKey(nostrPublicKey)) { + log.i("asked to forget a key that is not a bare key on this device") + return ForgetNostrKeyResult.NotABareKey + } + + NostrKeyManager.writeToDisk(phoenixGlobal, EncryptedNostrKeys.encrypt(existing - nostrPublicKey)) + log.i("forgot nostr key for identity=$id") + + DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(id) + + return ForgetNostrKeyResult.Forgotten + } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index aaf5147a..2ea083e5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -73,6 +73,13 @@ interface NostrRepository { biography: String?, ) + /** + * Stops treating [publicKey] as one of this device's own: its unsigned rows go, and + * with them the requests that hang off them. What it published, and its profile, stay + * cached as anyone else's would. + */ + suspend fun forgetLocalAccount(publicKey: HexKey) + suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -260,6 +267,10 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun forgetLocalAccount(publicKey: HexKey) { + TODO("Not yet implemented") + } + override suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index ed56c4a1..b7e12aa3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.AddToDrive import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.LocalHospital +import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Spellcheck import androidx.compose.material.icons.filled.VolunteerActivism import androidx.compose.material.icons.filled.Warning @@ -42,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf @@ -52,7 +54,11 @@ import mantra.composeapp.generated.resources.emergency_kit import mantra.composeapp.generated.resources.encrypt_and_back_your_recovery_information import mantra.composeapp.generated.resources.key_recovery import mantra.composeapp.generated.resources.not_backed_up_yet +import mantra.composeapp.generated.resources.nostr_secret_key import mantra.composeapp.generated.resources.recovery_phrase +import mantra.composeapp.generated.resources.store_the_nsec_that_signs_as_this_profile +import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_no_wallet +import mantra.composeapp.generated.resources.you_said_you_stored_it import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_so_they import mantra.composeapp.generated.resources.write_down_and_secure_the_12_word_phrase import mantra.composeapp.generated.resources.yolo @@ -61,6 +67,7 @@ import mantra.composeapp.generated.resources.you_said_you_wrote_it_down import org.jetbrains.compose.resources.stringResource import press.mantra.compose.extensions.hexToNpubHrp import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.Decorative @@ -71,11 +78,12 @@ import press.mantra.compose.ui.theme.spacing /** * Everything that can put this profile back on another phone. * - * A mantra profile is its key: the npub that signs, and the wallet that holds - * coins, both come out of one seed that never leaves the device. There is no - * account to reset, so this screen is the only thing standing between a lost - * phone and a lost identity -- it exists to get the user to take a backup while - * they still can. + * A Mantra profile is its key: the npub that signs and, for a wallet, the coins + * it holds, both come out of one secret that never leaves the device -- twelve + * words, or a bare nostr key that was signed in with. There is no account to + * reset, so this screen is the only thing standing between a lost phone and a + * lost identity -- it exists to get the user to take a backup while they still + * can. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -133,7 +141,11 @@ fun KeyRecoveryScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they), + // A bare-key identity has no coins to speak of. + text = when (activeIdentity?.kind) { + IdentityKind.NostrSecret -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet) + else -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they) + }, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center ) @@ -147,27 +159,55 @@ fun KeyRecoveryScreen( } item { - KeyRecoveryOption( - icon = Icons.Default.Spellcheck, - title = stringResource(Res.string.recovery_phrase), - description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase), - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - status = if (showBackupNotice) { - KeyRecoveryStatus( - icon = Icons.Default.Warning, - text = stringResource(Res.string.not_backed_up_yet) - ) - } else { - KeyRecoveryStatus( - icon = Icons.Default.CheckCircle, - text = stringResource(Res.string.you_said_you_wrote_it_down) - ) - }, - onClick = { - onNavigateToRoute.invoke(RecoveryPhraseRoute) - } - ) + // The backup an identity has depends on what it was made from. Twelve words + // for a wallet; for a bare key there is no phrase, and the key is the whole + // backup. Same flags underneath -- they are per identity and mean "this + // identity's secret is not backed up" for whichever secret it is. + when (activeIdentity?.kind) { + IdentityKind.NostrSecret -> KeyRecoveryOption( + icon = Icons.Default.Key, + title = stringResource(Res.string.nostr_secret_key), + description = stringResource(Res.string.store_the_nsec_that_signs_as_this_profile), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + status = if (showBackupNotice) { + KeyRecoveryStatus( + icon = Icons.Default.Warning, + text = stringResource(Res.string.not_backed_up_yet) + ) + } else { + KeyRecoveryStatus( + icon = Icons.Default.CheckCircle, + text = stringResource(Res.string.you_said_you_stored_it) + ) + }, + onClick = { + onNavigateToRoute.invoke(NostrSecretRoute) + } + ) + + else -> KeyRecoveryOption( + icon = Icons.Default.Spellcheck, + title = stringResource(Res.string.recovery_phrase), + description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase), + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + status = if (showBackupNotice) { + KeyRecoveryStatus( + icon = Icons.Default.Warning, + text = stringResource(Res.string.not_backed_up_yet) + ) + } else { + KeyRecoveryStatus( + icon = Icons.Default.CheckCircle, + text = stringResource(Res.string.you_said_you_wrote_it_down) + ) + }, + onClick = { + onNavigateToRoute.invoke(RecoveryPhraseRoute) + } + ) + } } item { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt new file mode 100644 index 00000000..12d49d6c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt @@ -0,0 +1,490 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.back +import mantra.composeapp.generated.resources.backup_confirmation +import mantra.composeapp.generated.resources.cancel +import mantra.composeapp.generated.resources.copied_the_nsec +import mantra.composeapp.generated.resources.copy_nsec +import mantra.composeapp.generated.resources.could_not_forget_the_key_please_try_again +import mantra.composeapp.generated.resources.could_not_unlock_your_key_please_try +import mantra.composeapp.generated.resources.mantra_will_delete_the_nsec_for_s_from_this +import mantra.composeapp.generated.resources.display_nostr_secret_key +import mantra.composeapp.generated.resources.forget +import mantra.composeapp.generated.resources.forget_this_key +import mantra.composeapp.generated.resources.forget_this_key_question +import mantra.composeapp.generated.resources.hide +import mantra.composeapp.generated.resources.i_have_saved_my_nostr_secret_key_somewhere +import mantra.composeapp.generated.resources.i_understand_that_if_i_lose_this_phone_and_my_key +import mantra.composeapp.generated.resources.keep_this_key_safe_do_not_share_it +import mantra.composeapp.generated.resources.key_forgotten +import mantra.composeapp.generated.resources.loading_preferences +import mantra.composeapp.generated.resources.lose_this_phone_before_you_do_and_the +import mantra.composeapp.generated.resources.no_identity_is_open_on_this_device_so_there +import mantra.composeapp.generated.resources.nostr_secret_key +import mantra.composeapp.generated.resources.remove_the_key_from_this_device_the_profile +import mantra.composeapp.generated.resources.the_nsec_is_the_key_that_signs_as_you_this +import mantra.composeapp.generated.resources.this_device_holds_no_key_for_the_profile +import mantra.composeapp.generated.resources.unlocking_your_key +import mantra.composeapp.generated.resources.you_have_not_backed_up_your_nostr_secret_key +import fr.acinq.phoenix.PhoenixGlobal +import kotlinx.coroutines.flow.StateFlow +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.NostrSecretViewModel +import press.mantra.compose.ui.view.state.NostrSecretUIState + +/** + * The nostr secret key behind a bare-key identity, shown once and on request. + * + * `RecoveryPhraseScreen` with the word grid replaced by the nsec, for the identity kind + * that has a key and no phrase. The reveal is a separate step from opening the screen + * for the same reason: reading the key goes to the keystore, and nobody should walk past + * a phone that is quietly displaying it. Below the backup section is the one thing this + * screen has that the phrase screen does not: the way to take the key off the device. + */ +@Composable +fun NostrSecretScreen( + phoenixGlobal: PhoenixGlobal, + activeIdentityStateFlow: StateFlow, + nostrRepository: NostrRepository, + forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + onNavigateBack: () -> Unit, + onForgotten: () -> Unit, +) { + val viewModel: NostrSecretViewModel = viewModel( + factory = NostrSecretViewModel.factory( + phoenixGlobal = phoenixGlobal, + activeIdentityStateFlow = activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrKey = forgetNostrKey, + hideIdentityMetadata = hideIdentityMetadata, + ) + ) + + val uiState by viewModel.uiState.collectAsState() + val forgetting by viewModel.forgetting.collectAsState() + val isBareKeyIdentity by viewModel.isBareKeyIdentity.collectAsState() + val isBackupDone by viewModel.isBackupDone.collectAsState() + val isDisclaimerRead by viewModel.isDisclaimerRead.collectAsState() + val showBackupNotice by viewModel.showBackupNotice.collectAsState() + val activeIdentity by activeIdentityStateFlow.collectAsState() + + // Both composable, and the handlers that show these are not: read above them. + val notify = rememberNotifier(rememberCoroutineScope()) + val keyForgotten = stringResource(Res.string.key_forgotten) + + NostrSecretContent( + uiState = uiState, + forgetting = forgetting, + npub = activeIdentity?.nostrPublicKey?.hexToNpubHrp(), + canForget = isBareKeyIdentity, + isBackupDone = isBackupDone, + isDisclaimerRead = isDisclaimerRead, + showBackupNotice = showBackupNotice, + onReveal = viewModel::revealNostrSecret, + onHide = viewModel::hideNostrSecret, + onBackupDoneChange = viewModel::setBackupDone, + onDisclaimerReadChange = viewModel::setDisclaimerRead, + onAskToForget = viewModel::askToForgetKey, + onCancelForget = viewModel::cancelForgetKey, + onForget = { + viewModel.forgetKey { + notify(keyForgotten) + onForgotten() + } + }, + onNavigateBack = onNavigateBack, + ) +} + +/** + * The screen once the state and the preferences have been read. Everything the view + * model owns arrives as a value or a callback, so a preview can render any branch + * without a key on disk. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun NostrSecretContent( + uiState: NostrSecretUIState, + forgetting: NostrSecretUIState.Forgetting, + npub: String?, + canForget: Boolean, + isBackupDone: Boolean?, + isDisclaimerRead: Boolean?, + showBackupNotice: Boolean, + onReveal: () -> Unit, + onHide: () -> Unit, + onBackupDoneChange: (Boolean) -> Unit, + onDisclaimerReadChange: (Boolean) -> Unit, + onAskToForget: () -> Unit, + onCancelForget: () -> Unit, + onForget: () -> Unit, + onNavigateBack: () -> Unit, +) { + if (forgetting is NostrSecretUIState.Forgetting.Confirming) { + AlertDialog( + onDismissRequest = onCancelForget, + icon = { Icon(Icons.Default.DeleteForever, contentDescription = Decorative) }, + title = { Text(stringResource(Res.string.forget_this_key_question)) }, + text = { Text(stringResource(Res.string.mantra_will_delete_the_nsec_for_s_from_this, npub ?: "")) }, + confirmButton = { + TextButton(onClick = onForget) { Text(stringResource(Res.string.forget)) } + }, + dismissButton = { + TextButton(onClick = onCancelForget) { Text(stringResource(Res.string.cancel)) } + }, + ) + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(text = stringResource(Res.string.nostr_secret_key)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = MaterialTheme.spacing.containerPadding), + text = stringResource(Res.string.the_nsec_is_the_key_that_signs_as_you_this), + style = MaterialTheme.typography.bodyMedium + ) + } + + if (showBackupNotice) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Warning, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Column { + Text( + text = stringResource(Res.string.you_have_not_backed_up_your_nostr_secret_key), + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = stringResource(Res.string.lose_this_phone_before_you_do_and_the), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is NostrSecretUIState.Hidden -> { + Button(onClick = onReveal) { + Icon(Icons.Default.Visibility, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(text = stringResource(Res.string.display_nostr_secret_key)) + } + } + + is NostrSecretUIState.Revealing -> { + LoadingDataIndicator( + fillScreen = false, + text = stringResource(Res.string.unlocking_your_key) + ) + } + + is NostrSecretUIState.Revealed -> { + RevealedNsec(nsec = uiState.nsec) + + TextButton(onClick = onHide) { + Icon(Icons.Default.VisibilityOff, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(text = stringResource(Res.string.hide)) + } + } + + is NostrSecretUIState.Error -> { + ErrorState( + message = when (uiState) { + is NostrSecretUIState.Error.NoActiveIdentity -> + stringResource(Res.string.no_identity_is_open_on_this_device_so_there) + is NostrSecretUIState.Error.NoKeyForThisIdentity -> + stringResource(Res.string.this_device_holds_no_key_for_the_profile) + is NostrSecretUIState.Error.KeysUnreadable -> + stringResource(Res.string.could_not_unlock_your_key_please_try) + }, + // Retrying without an identity, or for one whose key is in + // the seed rather than here, cannot help. + onRetry = when (uiState) { + is NostrSecretUIState.Error.KeysUnreadable -> onReveal + else -> null + } + ) + } + } + } + } + } + + item { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.backup_confirmation), + style = MaterialTheme.typography.titleMedium + ) + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + if (isBackupDone == null || isDisclaimerRead == null) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + text = stringResource(Res.string.loading_preferences), + style = MaterialTheme.typography.bodySmall + ) + } else { + BackupCheckbox( + checked = isBackupDone, + text = stringResource(Res.string.i_have_saved_my_nostr_secret_key_somewhere), + onCheckedChange = onBackupDoneChange + ) + + BackupCheckbox( + checked = isDisclaimerRead, + text = stringResource(Res.string.i_understand_that_if_i_lose_this_phone_and_my_key), + onCheckedChange = onDisclaimerReadChange + ) + } + } + } + + if (canForget) { + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + Card( + modifier = Modifier.fillMaxWidth(), + onClick = onAskToForget, + enabled = forgetting !is NostrSecretUIState.Forgetting.InProgress, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.DeleteForever, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text( + text = stringResource(Res.string.forget_this_key), + style = MaterialTheme.typography.titleMedium + ) + } + Text( + text = when (forgetting) { + is NostrSecretUIState.Forgetting.Failed -> + stringResource(Res.string.could_not_forget_the_key_please_try_again) + else -> stringResource(Res.string.remove_the_key_from_this_device_the_profile) + }, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.containerPadding)) + } + } + } +} + +/** The key itself, selectable and with a copy action, since nobody transcribes sixty-three characters by hand. */ +@Composable +private fun RevealedNsec(nsec: String) { + val clipboardManager = LocalClipboardManager.current + val notify = rememberNotifier(rememberCoroutineScope()) + val copied = stringResource(Res.string.copied_the_nsec) + + Text( + text = stringResource(Res.string.keep_this_key_safe_do_not_share_it), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center + ) + + SelectionContainer { + Text( + text = nsec, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + textAlign = TextAlign.Center + ) + } + + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(nsec)) + notify(copied) + } + ) { + Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(text = stringResource(Res.string.copy_nsec)) + } +} + +@Composable +private fun BackupCheckbox( + checked: Boolean, + text: String, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + // The whole row toggles, so the row is the target and has to carry the 48dp + // the Checkbox alone would. + .minimumInteractiveComponentSize() + .clickable { onCheckedChange(!checked) } + .padding( + horizontal = MaterialTheme.spacing.containerPadding, + vertical = MaterialTheme.spacing.space150 + ), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(text = text, style = MaterialTheme.typography.bodyMedium) + } +} + +@ConformancePreviews +@Composable +private fun NostrSecretScreenPreview() { + press.mantra.compose.ui.theme.MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + NostrSecretContent( + uiState = NostrSecretUIState.Revealed("nsec1" + "q".repeat(58)), + forgetting = NostrSecretUIState.Forgetting.Idle, + npub = "npub1" + "q".repeat(58), + canForget = true, + isBackupDone = false, + isDisclaimerRead = true, + showBackupNotice = true, + onReveal = {}, + onHide = {}, + onBackupDoneChange = {}, + onDisclaimerReadChange = {}, + onAskToForget = {}, + onCancelForget = {}, + onForget = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 7048f9df..3e0753a9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -52,6 +52,7 @@ import press.mantra.compose.ui.composable.KeyRecoveryScreen import press.mantra.compose.ui.composable.LandingScreen import press.mantra.compose.ui.composable.LoadingScreen import press.mantra.compose.ui.composable.NostrEventDetailScreen +import press.mantra.compose.ui.composable.NostrSecretScreen import press.mantra.compose.ui.composable.RecoveryPhraseScreen import press.mantra.compose.ui.composable.SearchMemberToAddToChatRoomScreen import press.mantra.compose.ui.composable.SearchResultScreen @@ -87,6 +88,7 @@ import press.mantra.compose.ui.composable.navigation.routes.KeyRecoveryRoute import press.mantra.compose.ui.composable.navigation.routes.LandingRoute import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute @@ -865,6 +867,27 @@ fun MantraNavHost( } ) } + composable { + NostrSecretScreen( + phoenixGlobal = phoenixGlobal, + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, + nostrRepository = databaseNostrRepository, + forgetNostrKey = { identity -> sovereignWalletViewModel.forgetNostrKey(identity) }, + hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, + onNavigateBack = { + navController.popBackStack() + }, + // The device no longer holds the key. Re-list so the selector drops it, + // then clear the active identity: the navigation observer sends a null + // identity to startup, which shows the selector -- or Landing, if this + // was the last one. + onForgotten = { + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.resetToSelector() + } + }, + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt new file mode 100644 index 00000000..c7bceaca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/NostrSecretRoute.kt @@ -0,0 +1,7 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** The nostr secret key behind a bare-key identity: shown on request, and the way to forget it. */ +@Serializable +data object NostrSecretRoute : Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt new file mode 100644 index 00000000..6662236e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt @@ -0,0 +1,213 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.DecryptNostrKeysResult +import fr.acinq.phoenix.managers.NostrKeyManager +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.extensions.hexToNsecHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.NostrSecretUIState + +/** + * Reads back the nostr secret key an identity was signed in with. + * + * The counterpart of [RecoveryPhraseViewModel] for the identity kind that has a key and + * no phrase. There is no wallet behind such an identity, so the key is the whole of the + * backup, and everything here is about getting it in front of the user once and + * recording that they say they stored it. + * + * The key is read from the file at reveal time, not off the active identity, so the + * screen's contract -- nothing secret held longer than it is shown -- is the same as the + * phrase screen's. The backup flags are the same per-identity preferences the phrase + * screen writes; they mean "this identity's secret is not backed up" for whichever + * secret it is. + * + * It also owns the inverse of an import: [forgetKey], which removes the key from the + * device and the account with it. Only for a bare key; a mnemonic identity's key is in + * the seed, and removing a seed is a wallet question this does not answer. + */ +class NostrSecretViewModel( + val phoenixGlobal: PhoenixGlobal, + val activeIdentityStateFlow: StateFlow, + private val nostrRepository: NostrRepository, + private val forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + private val hideIdentityMetadata: suspend (Identity) -> Unit, +) : ViewModel() { + + private val logger = Logger.withTag(TAG) + + private val _uiState = MutableStateFlow(NostrSecretUIState.Hidden) + val uiState = _uiState.asStateFlow() + + private val _forgetting = MutableStateFlow(NostrSecretUIState.Forgetting.Idle) + val forgetting = _forgetting.asStateFlow() + + /** Whether the active identity is one whose key this screen can show and forget. */ + @OptIn(ExperimentalCoroutinesApi::class) + val isBareKeyIdentity: StateFlow = activeIdentityStateFlow + .flatMapLatest { flowOf(it?.kind == IdentityKind.NostrSecret) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + @OptIn(ExperimentalCoroutinesApi::class) + val isBackupDone: StateFlow = activeIdentityStateFlow + .flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + @OptIn(ExperimentalCoroutinesApi::class) + val isDisclaimerRead: StateFlow = activeIdentityStateFlow + .flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + @OptIn(ExperimentalCoroutinesApi::class) + val showBackupNotice: StateFlow = activeIdentityStateFlow + .flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + fun revealNostrSecret() { + if (_uiState.value is NostrSecretUIState.Revealing) return + + val identity = activeIdentityStateFlow.value + if (identity == null) { + logger.e { "no active identity, cannot read a nostr key" } + _uiState.value = NostrSecretUIState.Error.NoActiveIdentity + return + } + + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("failed to read the nostr keys", throwable) + _uiState.value = NostrSecretUIState.Error.KeysUnreadable + }) { + _uiState.value = NostrSecretUIState.Revealing + + // Keystore-backed, and it reads a file: it blocks, so it cannot run on the main thread. + val result = withContext(Dispatchers.IO) { + NostrKeyManager.loadAndDecrypt(phoenixGlobal) + } + + _uiState.value = when (result) { + is DecryptNostrKeysResult.Success -> { + val privateKey = result.keys[identity.nostrPublicKey] + if (privateKey == null) { + logger.e { "key file holds no key for identity=${identity.id}" } + NostrSecretUIState.Error.NoKeyForThisIdentity + } else { + NostrSecretUIState.Revealed(privateKey.value.toHex().hexToNsecHrp()) + } + } + is DecryptNostrKeysResult.Failure.FileNotFound -> { + logger.e { "no key file, so no key for identity=${identity.id}" } + NostrSecretUIState.Error.NoKeyForThisIdentity + } + is DecryptNostrKeysResult.Failure -> { + logger.e { "unable to read the nostr keys: $result" } + NostrSecretUIState.Error.KeysUnreadable + } + } + } + } + + fun hideNostrSecret() { + _uiState.value = NostrSecretUIState.Hidden + } + + fun setBackupDone(isDone: Boolean) { + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("could not save the backup flag", throwable) + }) { + internalPrefs.saveManualSeedBackupDone(isDone) + } + } + + fun setDisclaimerRead(isRead: Boolean) { + val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + logger.e("could not save the disclaimer flag", throwable) + }) { + internalPrefs.saveSeedLossDisclaimerRead(isRead) + } + } + + fun askToForgetKey() { + _forgetting.value = NostrSecretUIState.Forgetting.Confirming + } + + fun cancelForgetKey() { + _forgetting.value = NostrSecretUIState.Forgetting.Idle + } + + /** + * Key out of the file, preferences deleted, metadata hidden, account rows gone -- in + * that order, so a failure partway leaves the key on disk rather than an identity the + * selector lists but nothing can open. [onForgotten] runs on the main thread once the + * device no longer holds the key; the caller clears the active identity and re-lists. + */ + fun forgetKey(onForgotten: () -> Unit) { + val identity = activeIdentityStateFlow.value ?: return + if (_forgetting.value is NostrSecretUIState.Forgetting.InProgress) return + _forgetting.value = NostrSecretUIState.Forgetting.InProgress + _uiState.value = NostrSecretUIState.Hidden + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.e("could not forget the key", throwable) + _forgetting.value = NostrSecretUIState.Forgetting.Failed + }) { + when (forgetNostrKey(identity)) { + is IdentityWriter.ForgetNostrKeyResult.Forgotten -> { + hideIdentityMetadata(identity) + nostrRepository.forgetLocalAccount(identity.nostrPublicKey) + _forgetting.value = NostrSecretUIState.Forgetting.Idle + withContext(Dispatchers.Main) { onForgotten() } + } + is IdentityWriter.ForgetNostrKeyResult.NotABareKey, + is IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys -> { + _forgetting.value = NostrSecretUIState.Forgetting.Failed + } + } + } + } + + companion object { + private const val TAG = "NostrSecretViewModel" + + fun factory( + phoenixGlobal: PhoenixGlobal, + activeIdentityStateFlow: StateFlow, + nostrRepository: NostrRepository, + forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + NostrSecretViewModel( + phoenixGlobal = phoenixGlobal, + activeIdentityStateFlow = activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrKey = forgetNostrKey, + hideIdentityMetadata = hideIdentityMetadata, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index a22d6ba5..9997f884 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -367,6 +367,30 @@ class SovereignWalletViewModel( customElectrumServer = customElectrumServer.value, ) + /** The inverse of [writeNostrKey]. Only for a bare key; see [IdentityWriter.forgetNostrKey]. */ + suspend fun forgetNostrKey(identity: Identity): IdentityWriter.ForgetNostrKeyResult = + IdentityWriter.forgetNostrKey( + log = log, + phoenixGlobal = phoenixGlobal, + id = identity.id, + nostrPublicKey = identity.nostrPublicKey, + ) + + /** + * Hides a forgotten identity's entry in the wallet metadata rather than deleting it: + * the metadata store has no delete, and `isHidden` is what the selector filters on. + */ + suspend fun hideIdentityMetadata(identity: Identity) { + val existing = getAvailableWalletsMeta(phoenixGlobal).first()[identity.id] ?: return + saveAvailableWalletMeta( + phoenixGlobal = phoenixGlobal, + walletId = identity.id, + name = existing.name, + avatar = existing.avatar, + isHidden = true, + ) + } + companion object { private const val TAG = "SovereignWalletViewModel" fun factory( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt new file mode 100644 index 00000000..9e0754e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/NostrSecretUIState.kt @@ -0,0 +1,35 @@ +package press.mantra.compose.ui.view.state + +/** + * What the nostr secret key screen is showing. The key is never held here longer than + * the user is looking at it: [Revealed] is the only state that carries it, and leaving + * the screen (or hiding it) drops back to [Hidden]. The shape of [RecoveryPhraseUIState], + * for the identity kind that has a key and no phrase. + */ +sealed interface NostrSecretUIState { + data object Hidden : NostrSecretUIState + + /** Reading and decrypting the key file. */ + data object Revealing : NostrSecretUIState + + data class Revealed(val nsec: String) : NostrSecretUIState + + sealed interface Error : NostrSecretUIState { + /** No identity is active, so there is nothing to look up. */ + data object NoActiveIdentity : Error + + /** The key file was read, but holds no key for the active identity -- it is a mnemonic identity, or the key was forgotten. */ + data object NoKeyForThisIdentity : Error + + /** The key file could not be read or decrypted. */ + data object KeysUnreadable : Error + } + + /** The forget flow's own states, alongside whatever the reveal is showing. */ + sealed interface Forgetting { + data object Idle : Forgetting + data object Confirming : Forgetting + data object InProgress : Forgetting + data object Failed : Forgetting + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt new file mode 100644 index 00000000..d18e4c2c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/ForgetLocalAccountJvmTest.kt @@ -0,0 +1,56 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.nostr.SignInSync +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Forgetting a key at the rows: the account it made goes, and so does everything that + * hung off it -- while an unrelated account is untouched. + */ +class ForgetLocalAccountJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val forgotten = "a".repeat(64) + private val kept = "b".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `the account and its requests go, the other account stays`() = runBlocking { + repository.signInToProfile(forgotten) + repository.signInToProfile(kept) + val account = repository.getLocalAccounts().first { it.unsignedNostrEvent?.pubKey == forgotten } + val placeholderId = assertNotNull(account.unsignedNostrEvent).id + repository.queueSynchronizeNostrEvent( + SignInSync.requests(placeholderId, forgotten, listOf(RelayUrlNormalizer.normalize("wss://one.example")), level = 0) + ) + assertEquals(1, repository.observeLocalAccount(forgotten).first()?.synchronizeNostrEventRequests?.size) + + repository.forgetLocalAccount(forgotten) + + assertEquals(listOf(kept), repository.getLocalAccounts().map { it.unsignedNostrEvent?.pubKey }) + assertEquals(0, db.synchronizeNostrEventRequestDao().observeSynchronizeNostrEventRequestByPurposeAndStatusCount(SignInSync.PURPOSE, listOf("pending")).first(), + "the requests cascaded with the row they hung off") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt new file mode 100644 index 00000000..db77f8b4 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt @@ -0,0 +1,122 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.runBlocking +import okio.FileSystem +import okio.SYSTEM +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The two writers for a bare key, against a real key store and a real directory. + * + * What is pinned is the duplicate rule and the inverse. A second import of the same key + * is refused by public key, the same npub imported twice being the one thing the id + * check cannot see; and forgetting takes the key out of the file and the identity's + * preference files off the disk, leaving every other key where it was. + */ +class IdentityWriterJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + private lateinit var globalPrefs: GlobalPrefs + + private val log = Logger.withTag("IdentityWriterJvmTest") + + // Fresh keys per test, not fixed ones: DataStoreManager caches each id's preferences + // in a companion object for the life of the process, so a fixed key would be served + // the UserPrefs an earlier test created in an earlier temp directory. + private val first = PrivateKey(Lightning.randomBytes(32)) + private val second = PrivateKey(Lightning.randomBytes(32)) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-identity-writer-store").toFile() + appDir = Files.createTempDirectory("mantra-identity-writer-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + globalPrefs = GlobalPrefs( + PreferenceDataStoreFactory.createWithPath { + computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") + } + ) + } + + @AfterTest + fun tearDown() { + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private suspend fun write(key: PrivateKey) = IdentityWriter.writeNostrKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = globalPrefs, + privateKey = key, + isTorEnabled = false, + customElectrumServer = null, + ) + + private fun userPrefsFile(stored: StoredIdentity.NostrSecret) = + computePreferencePath(phoenixGlobal.ctx, "userprefs_${stored.id.nodeIdHash}.preferences_pb") + + @Test + fun `a key is written once and refused the second time, by public key`() = runBlocking { + val stored = StoredIdentity.nostrSecret(first) + + val written = assertIs(write(first)) + assertEquals(stored.id, written.id) + assertEquals(mapOf(stored.nostrPublicKey to first), NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)), "the preferences file the recovery screens read") + + assertIs(write(first)) + } + + @Test + fun `forgetting one key leaves the other, and takes the preferences with it`() = runBlocking { + write(first) + write(second) + val stored = StoredIdentity.nostrSecret(first) + + val result = IdentityWriter.forgetNostrKey(log, phoenixGlobal, stored.id, stored.nostrPublicKey) + + assertIs(result) + assertEquals( + mapOf(StoredIdentity.nostrSecret(second).nostrPublicKey to second), + NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal), + ) + assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(stored))) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrSecret(second)))) + } + + /** A wallet's nostr key is in the seed, not here; removing a seed is a wallet question. */ + @Test + fun `a key that is not in the file is not forgotten`() = runBlocking { + write(first) + val other = StoredIdentity.nostrSecret(second) + + assertIs( + IdentityWriter.forgetNostrKey(log, phoenixGlobal, other.id, other.nostrPublicKey) + ) + assertEquals(1, NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)?.size) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt new file mode 100644 index 00000000..a6bd120c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt @@ -0,0 +1,120 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.NostrSecretUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Forgetting a key is four effects, and their order is what this pins: the key comes + * out of the file first, and only then are the metadata hidden, the account rows + * removed and the caller told. A failure at the first step must leave everything else + * untouched -- an identity the selector lists but nothing can open is worse than one + * that is still there. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrSecretViewModelJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("01".repeat(32))) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount" + } + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun identity() = Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = privateKey, + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + business = null, + ) + + private fun viewModel( + recorder: Recorder, + forgetOutcome: IdentityWriter.ForgetNostrKeyResult, + ) = NostrSecretViewModel( + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = temporaryFolder.newFolder())), + activeIdentityStateFlow = MutableStateFlow(identity()), + nostrRepository = recorder, + forgetNostrKey = { recorder.effects += "forgetNostrKey"; forgetOutcome }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `the key comes out first, then the metadata, the account, and the caller is told`() { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.Forgotten) + val told = CompletableDeferred() + + viewModel.forgetKey { told.complete(Unit) } + runBlocking { withTimeout(10_000) { told.await() } } + + assertEquals( + listOf("forgetNostrKey", "hideIdentityMetadata", "forgetLocalAccount"), + recorder.effects, + ) + assertEquals(NostrSecretUIState.Forgetting.Idle, viewModel.forgetting.value) + } + + @Test + fun `a key that could not be removed leaves everything else alone`() { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys) + + viewModel.forgetKey { error("must not be told") } + val state = runBlocking { + withTimeout(10_000) { viewModel.forgetting.first { it !is NostrSecretUIState.Forgetting.InProgress } } + } + + assertEquals(NostrSecretUIState.Forgetting.Failed, state) + assertEquals(listOf("forgetNostrKey"), recorder.effects) + } +} From 9e6aa382af6da6e572ca7161b492d2f9217ad24f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 12:29:47 +0200 Subject: [PATCH 19/71] test(identity): the restore round trip, and that no node ran Phase 7 of docs/nsec-sign-in.md. Phases 2 through 6 each pin one seam; this is the seams in a row, with the network faked at the repository. NsecRestoreRoundTripJvmTest unlocks the jvm key store into a temporary directory, writes an nsec the way the sign-in screen's commit does, lists identities the way startup does (both stores, merged) and asserts the key is listed under the id the writer returned, builds the Identity the startup screen's NostrSecret branch builds -- business = null -- and hands NavigationViewModel a device holding the placeholder account the sign-in planted. It must land on UnqueuedProfileSynchronization: fetch, do not create. Then the device's account gains an indexed kind 0, as the rows would when the relays answer, and the same observer must move to ProfileLoaded. And the assertion nothing in the UI would reveal: no Lightning node ran. The identity has no business behind it, and the jvm BusinessManager's flow of running businesses is empty afterwards. An nsec identity that quietly started a node -- Electrum, the LSP peer, the watchers -- would be a bug with no visible symptom, which is why it is asserted here rather than noticed later. The device fake holds its account in a MutableStateFlow rather than a flowOf, so the second step is the observer seeing a change, as it would in the app, not a second call. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (902 tests) and :composeApp:m3Audit. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@647ee815c1ca3942fa53caedd403e1c00e7f2c3d --- .../identity/NsecRestoreRoundTripJvmTest.kt | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt new file mode 100644 index 00000000..01c30bc6 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt @@ -0,0 +1,173 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.state.NavigationUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The path a user takes, end to end, with the network faked at the repository. + * + * Unlock the key store into a temporary directory, write an nsec the way the sign-in + * screen does, list identities the way startup does, activate the one that came back, + * and hand NavigationViewModel a device holding the placeholder account the sign-in + * planted: it must land on UnqueuedProfileSynchronization. Then the same account with + * a kind 0 indexed: ProfileLoaded. Phases 2 through 5 each pin one seam; this is the + * seams in a row. + * + * And the assertion nothing in the UI would reveal: no Lightning node ran. The jvm + * BusinessManager's flow of running businesses is empty afterwards, and the identity + * has no business behind it. + */ +class NsecRestoreRoundTripJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + private lateinit var globalPrefs: GlobalPrefs + + private val log = Logger.withTag("NsecRestoreRoundTripJvmTest") + private val privateKey = PrivateKey(Lightning.randomBytes(32)) + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-round-trip-store").toFile() + appDir = Files.createTempDirectory("mantra-round-trip-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + globalPrefs = GlobalPrefs( + PreferenceDataStoreFactory.createWithPath { + computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") + } + ) + } + + @AfterTest + fun tearDown() { + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + /** A device whose one account can change under the observer, as rows do. */ + private class Device(initial: LocalAccount?) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val account = MutableStateFlow(initial) + override suspend fun getLocalAccounts(): List = listOfNotNull(account.value) + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = account + } + + private fun placeholder(publicKey: HexKey) = LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}", + signedAt = GENESIS_AT, createdAt = at, updatedAt = at, savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = emptyList(), + ) + + private fun synced(publicKey: HexKey) = placeholder(publicKey).copy( + profile = Profile(publicKey = publicKey, nostrEventId = "e".repeat(64), createdAt = at, updatedAt = at, savedAt = at), + ) + + @Test + fun `an nsec written, listed, activated and routed, with no node started`() = runBlocking { + // 1. The sign-in screen's commit, minus the screen. + val written = assertIs( + IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, privateKey, isTorEnabled = false, customElectrumServer = null) + ) + + // 2. What startup lists: both stores, merged. + val identities = StoredIdentity.merge( + wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"), + nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("key store unreadable"), + ) + val stored = assertIs(identities[written.id], "the imported key is listed under the id the writer returned") + + // 3. What the startup screen's NostrSecret branch does: an identity, no node. + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val identity = Identity( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + val activeIdentity = MutableStateFlow(null) + val device = Device(placeholder(identity.nostrPublicKey)) + val scope = CoroutineScope(Job()) + val navigation = NavigationViewModel( + activeIdentityStateFlow = activeIdentity, + initialNavigationUIState = NavigationUIState.Loading("Introducing... Torch"), + nostrRepository = device, + scope = scope, + ) + + try { + activeIdentity.value = identity + + // 4. The machine takes it from the placeholder: fetch, do not create. + val first = withTimeout(15_000) { + navigation.navigationUIState.first { it !is NavigationUIState.Loading } + } + assertEquals( + NavigationUIState.UnqueuedProfileSynchronization(unsignedNostrEvent = placeholder(identity.nostrPublicKey).unsignedNostrEvent!!), + first, + ) + + // 5. The relays answered, the kind 0 was indexed: home. + device.account.value = synced(identity.nostrPublicKey) + val second = withTimeout(15_000) { + navigation.navigationUIState.first { it is NavigationUIState.ProfileLoaded } + } + assertEquals(NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), second) + } finally { + scope.cancel() + } + + // 6. Nothing Lightning ran. + assertNull(identity.business) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a bare key") + } +} From 14ffa04e7c1918374bab05ebbc82936eaea2cec3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 12:30:45 +0200 Subject: [PATCH 20/71] docs: record what the nsec sign-in plan built, and the twelve places it chose differently Phase 8 of docs/nsec-sign-in.md is the rollout, which is process rather than code; what is left to write down is what the seven phases before it actually turned out to be. The header moves from "Not built" to "Built", with the table the other phased plans keep: what the plan said against what the implementation did, twelve rows, each one a decision worth reading before touching the code it describes -- the not-found exit as a screen state rather than a route, input problems on the field rather than through ErrorState, the kind 0 written over the placeholder rather than beside it, forget taking the account rows too. Two findings that belong to no phase are recorded with it: the android source set's file-name collision that put the failure classifier in its own file, and DataStoreManager's process-global preferences cache, which only a test reusing a key across directories could have found. And the one rollout fact that matters: the library commit is on claude/nostr-key-store in the submodule, bumped in by the Phase 3 commit, and has to be pushed and tagged with this branch. The docs index now says the plan has been built and points at the table. Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@2326839e48c8dd71b6225aa6a0216b809f3f9b73 --- docs/README.md | 4 ++-- docs/nsec-sign-in.md | 40 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index c5857a54..feb75ecb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,7 +40,7 @@ jvm-target note first if you want to know why its adaptive-layout phase exists. The curated-to-mantra note is a phased plan that has not been built, though its dry run has: it is about the repository rather than the app, and reads alone, except that its first decision leans on the derivation note's one rule. -The nsec sign-in note is a phased plan that has not been built; it inherits the +The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the -code open. +code open, and read its table of where the build chose differently first. diff --git a/docs/nsec-sign-in.md b/docs/nsec-sign-in.md index c9aad8b8..28e1a267 100644 --- a/docs/nsec-sign-in.md +++ b/docs/nsec-sign-in.md @@ -9,10 +9,35 @@ inherits, and with `NavigationViewModel.processLocalAccount` open for the state machine it drives — the machine is already built, and half of this plan is about finding its unreachable entrance. -**Not built.** Phases 1–8 below, one commit each, in the order given. Phases 1 and -2 are independent and can go in parallel; nothing after Phase 3 can start before -it, because Phase 3 is where the compiler first checks both halves against each -other. +**Built**, phases 1–7, one commit each, in the order given; Phase 8 is the rollout +and is process rather than code. The phases are kept as written because they are +the reasoning, and the code reads better against the argument it came from than +against a summary of itself. Where the implementation chose differently the +table below says so, and it did so in ways worth reading before touching any of +it: + +| what the plan said | what it turned out to be | +|---|---| +| `WalletManagerExtension.nostrPublicKey()` "can go with" the ten sites | it stays: `CreateProfileViewModel` derives a fresh key's pubkey with it. Wrong by one caller | +| pull the failure classification into `TechnicalExtensions.kt` | its own file, `DecryptionFailure.kt`. `androidMain` has a `TechnicalExtensions.kt` in the same package, and a common file of that name may hold only `expect`s — anything with a body is a second `TechnicalExtensionsKt` facade the android target refuses. The `graceful*Seed*` actuals were left as they were | +| "lift the write into a helper both managers call" | `AtomicFileWrite.writeVerified`, and `SeedManager.writeSeedToDir` now goes through it with its original check and exception type | +| the nsec branch of startup "reports the startup" itself | it only sets the identity; the existing `activeIdentity != null` branch reports it. The mnemonic path reports twice (once from `onStartupSuccess`, once from that branch); the nsec path does not repeat the mistake | +| read `loadNostrProfile(startupRoute)` by the active identity | done, with a fallback to the first account when there is no identity — which is how `NavigationRoutingTest` constructs it, and never how production reaches it | +| input problems "through `ErrorState`" | on the field, as supporting text, while the prompt is still showing. `ErrorState` is for what happens *after* confirming — the key was already here, the write failed — where there is no field to put the message under | +| `SignInToProfileUIState.Confirm(kind, npub, hexKey)` | `Confirm(credential)`, with the `SignInCredential` carrying the pubkey; the two writers are injected as functions so `commit` is a suspend function with a result, testable without a key store | +| the not-found exit as a route | a state of the *screen*: `UnsyncedProfileViewModel.decide` over the account, with `NavigationUIState` unchanged. A `processed` request — an event came back, just not a kind 0 — counts as finished; the plan's test sketch said the opposite and was wrong | +| "set one up" opens `CreateProfileScreen` | an inline form on the not-found state. `CreateProfileScreen` is built around generating a seed and says so in its copy; and the kind 0 has to be written *over* the placeholder row, not beside it — see `setUpProfileForExistingKey` | +| the one hop, unspecified where | in the sync pump, after `saveNostrEvent`, with a per-account set so five indexers answering with the same kind 10002 make one hop | +| forget: key, prefs, metadata, active identity | plus the account's unsigned rows (`forgetLocalAccount`), which the plan did not list: the kind 0 that made it a local account, and anything queued that can now never be signed. Published events and the profile cache stay | +| assert `platformStartupLogic` was not called | `identity.business == null` and the jvm `BusinessManager.businessFlow` empty afterwards — the observable fact rather than the call | + +Two things found on the way that are not in any phase. `DataStoreManager` caches +each id's preferences in a companion object for the life of the process, so a test +that reuses a key across temporary directories is served the wrong file +(`IdentityWriterJvmTest` uses fresh keys for that reason). And the library commit +is on `claude/nostr-key-store` in the submodule, at `59c11ed`, bumped into the app +by the Phase 3 commit; it has to be pushed with this branch, and tagged for JitPack +consumers, before any of this leaves the machine. ## The constraint @@ -903,6 +928,13 @@ throughout, so no build old or new misreads a wallet. if two people are on it. Phase 3 is where the estimate is least reliable: it is the first time both halves meet, and the startup screen has a history. +Phases 1–7 are now implemented, in one sitting and in order. What each turned out +to require, as against what was predicted here, is in the table at the top and in +the commit messages on this branch. The two surprises were not in any phase's +description: the android source set's file-name collision in Phase 2, and the +process-global preferences cache that only a test with a fixed key could have +found. + ## Out of scope - **A wallet for an nsec identity.** Not possible from the nsec, for the reason From b12764714507fcb127d15400ffc54403dd616433 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 12:54:34 +0200 Subject: [PATCH 21/71] feat(groups): the queue of a list the group curates, read off the relays it named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `930d37c8` gave a group the schema: the definition of a list, signed by the room's own key, for strangers to answer. This is the answers. Under every schema card on the group's screen there is now a "View suggestions" button, and it opens the list's *queue* -- every kind 31888 anyone has published in reply to the schema's coordinate, newest first, whoever signed it, with a mark on each one the group has already taken up into the list. It is the second half of the curated-list NIP in this app, the read side of it: suggestions (31888) and canonical entries (31890) are now read and checked here. They are still not written; that is the third half. The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that site's one list: one row per suggestion event rather than one per film, so that a reader can see what is waiting before anything is added and whether the curator has taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the reference for everything the screen decides -- one row per event, the newest version per coordinate, "Curated" where a canonical entry shares the `d`, and a timer standing in for an answer that never comes. **The button is behind no gate, and it is per card.** Every other button in the identity block is hidden from a member holding no share of the key, because each of them proposes a signature by the group. The queue is the one thing in the block the group did not write. A schema exists to be answered by strangers, and reading the answers takes no share of anything, so the button is there for whoever is looking -- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a schema and finds it. It sits under its own card rather than once for the section, in the same `item {}` as the card, because each list has its own queue and a button between two cards would say nothing about which one it opened. **The protocol is ported rule for rule, and the fixtures are the NIP's own.** `nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`, `verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind is the one the schema names; every required field is present; no field without `repeat` appears twice; every value matches its field's type and config; every `require-any` group is met; the `a` root names this schema and no other; and the author is somebody the visibility admits. A canonical entry gets two rules on top -- its author is the schema's author, because curation is the one power a schema does not delegate, and a source pointer, when there is one, is a well-formed suggestion coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest` takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's example, and the curator's sign-off on it, and checks both against the bitcoin.mov schema from the same document; then the doc's own minimum suggestion, which has an IMDb link and no watch link and satisfies `require-any` that way; then every rejection the doc lists and says why it matters -- an unknown type, a year outside 1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a `d` with a space in it -- plus the reply rules, the repeat rule, the closed-list rule and the two canonical rules. **Rejected, not repaired, and read by field.** The NIP says a client MUST verify an entry against its schema and MUST reject one that does not satisfy it rather than showing it with the bad parts blanked out; relays carry malformed, partial and hostile events from other applications. So `suggestion` and `canonical` return the entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema. problems` is an enum rather than a sentence -- so that a form later can show them inline. What comes back is a `CuratedEntry` keyed by the schema's *field names* rather than by tag, because a field is what a form and a screen know an entry by and the tag is only where it was kept: the two `r` tags of the example land on `watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no field of the NIP's schema and are not in the reading -- "tags the schema does not define MAY be present and MUST be ignored". **A pattern this platform cannot compile counts as not matched.** The reference builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring an uncompilable pattern would accept entries the site refuses; refusing them is the conservative reading, and it is documented at the check. **A coordinate splits on the first two colons only.** A `d` may itself contain colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses -- so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`, and the test pins the identifier surviving its own colon and the pubkey being lowercased on the way through. **The queue is a reading over stored rows, with three things to close and one to add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the queue out of it. Closed: an event that does not satisfy the schema; a kind 31890 signed by anybody but the group, which is somebody else's list and marks nothing; and an older version of an entry its author has since replaced, since both kinds are addressable and different relays hold different latest versions, so the merge that realises an edit happens here, newest per `kind:pubkey:d` with the event id breaking ties. Added: `isCurated`, true where a canonical entry the group signed shares the suggestion's identifier -- the coordinate both land on, and the reference page's rule. Newest first, because that is the order a queue is read in. **A stranger is named by their profile, and by their key until one arrives.** The indexer writes a placeholder profile named "LOADING..." the moment a pubkey is first seen, and a queue full of that would be a queue of nobody. `suggesterName` reads the profile only when it is a real one -- `createdAt` past `GENESIS_AT` -- and otherwise the short key, which is at least stable and distinguishing. The row's avatar is tinted from the key like every avatar here, so two suggestions by one stranger read as one stranger. **Read from where the schema says, and the screen says where.** The schema's `relay` tags are signed into the event for exactly this: a client that finds the list anywhere knows where its replies live and cannot be sent elsewhere by an unsigned config. A schema naming none -- the NIP allows it and lets a client pick -- is read from the group's general relay list, the *read* relays of it, since this is a read; and failing an agreed, non-empty one, from this build's own relay, the same fallback the schema editor seeds from. Spellings are normalised so one relay written two ways is one request. The header names the relays asked, because a queue read from the wrong relays and a list nobody has written to look exactly alike from here, and a member should be able to tell them apart. **Nothing here talks to a relay; it is a pull like every other one-off read.** `CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay for the sync pump to drain, carrying the NIP's two queries as one request's two filters -- everything anyone published in reply to the coordinate, and what the *group* published in reply to it, the second scoped by `authors` to spare the relay the work `queueOf` does regardless -- and watches the local table for what comes back. That watch needed a way to observe an arbitrary NIP-01 filter, which the repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes and falls back to text notes for the rest, and none of its shapes is "these kinds with this `a` tag". `observeNostrEventsMatching` applies the filter whole through `NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is anchored and escaped rather than a substring scan; the DAO method behind it names its observed tables explicitly, the events and the profiles joined onto them, so a row whose author's kind 0 arrives after the row does re-emits with the name filled in. What the screen shows is therefore what this device has *stored* for the coordinate -- which is also what it shows with no network at all, and what it showed last time until the relays answer again. `NostrEvent.toEvent` is the small conversion the reading needs to hand a stored row to a verifier written against the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the group's own. **"Still looking" is bounded by time, because nothing else bounds it.** The pump marks a request sent when the REQ goes out and processed when an event comes back. A relay holding nothing for the coordinate answers with an EOSE the pump does not record, so there is no signal for "asked and empty", and a screen that showed the local table's first, empty read would call every list empty for the second before its rows arrived -- which teaches a member not to believe it. So the queue is "being asked" from the request going out until either something lands or ten seconds pass, and only after that is an empty queue empty. The reference store does the same with a six-second timer; ten here because the request queues behind the pump's four subscription slots before it goes out. Rows arriving end it early, a late arrival after it is still shown, and `withQueue` -- the one state merge -- pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers "Ask again", which re-queues the same requests: the one thing the watch cannot do on its own is make more arrive. **The row shows what an entry is, not where to find it.** The screen is driven by a schema it has never seen, so what goes on a row is a decision rather than a layout: the title as the headline, since every list has one; a glance line of the form fields that say what the entry is, as `Label: value`, because a bare "2014 · en" says nothing to somebody who does not know the list's fields; the body where the list has one; and who suggested it, when. Links are left to the sheet -- a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it a member might act on: it says the group already took this up, and re-curating it would revise the entry rather than add one. **The sheet has the whole entry and the event it came as.** Every field the entry answered, labelled as the schema labels it and in the schema's order, so it reads as the form would have; the title not repeated, since it is the heading; tokens and links monospaced because those are compared and copied rather than read. Then the signed event, byte for byte, with a copy button -- the way the key state sheet shows the group's own statement and for the same reason: a prettier rendering is a different string to the one whose id was hashed. It is also the thing a canonical entry is built from, when that half arrives. **Four states, and the transition between them.** Loading while the room and the schema are read; an error, with nothing to retry, for a schema this device does not hold, since the identifier came from a navigation argument and reading it again fails the same way; and a loaded state that is either the rows, or looking, or empty with something to do. `ScreenStateTransition` wraps the `when`, which is the Scaffold's whole content. **The audit's proper-noun list grows by two films.** The preview suggests *The Rise and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and `m3-title-case.py` excludes sample data by name rather than by pattern. **Still nothing has reached a relay, and it bites once more.** Group-signed events are not yet published, so a group's schema has not been where a suggester could find it, and its queue is empty until it has. The screen reads the coordinate `31889::` all the same and shows whatever a relay holds for it; the suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at the reading. 32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion and canonical entry against the NIP's schema: the reading by field, the minimum suggestion, the source pointer and its absence, curation not being delegated, a malformed source, the wrong kind, the reply rules, a missing required field, the repeat rule both ways, every value rule the doc lists, `require-any`, the closed and private lists, `checkValue` on durations, patterns and https, and the coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows: verified suggestions newest first with the broken, the foreign and the wrong-kind ones dropped, an edit collapsing to its newest version, the curated mark placed by the group's canonical entry and not a stranger's, and the naming of a suggester with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest` (6) covers the relay order and the read-only filter, one request per relay carrying both queries and the watch covering both kinds, the three outcomes of `withQueue`, an `initiate` run end to end against fakes -- the relays asked, the filter watched, the rows shown, the looking ended -- and a missing schema being an error rather than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header, the row's glance line with the link kept off it, the mark on the right row and only that row, looking against empty, and the sheet with its fields, its link and its event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the button under each card in turn and in front of a member with no share. 1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@1e52fc8f25e29017f1acb72697166ecdfe99f320 --- .../composeResources/values/strings.xml | 19 +- .../compose/database/dao/NostrEventDao.kt | 11 + .../compose/database/model/NostrEvent.kt | 11 + .../repository/DatabaseNostrRepository.kt | 6 + .../mantra/compose/nostr/CuratedSuggestion.kt | 115 +++ .../compose/nostr/GroupCuratedSchema.kt | 51 ++ .../nostr/curated/CuratedEntryEvent.kt | 437 +++++++++++ .../compose/repository/NostrRepository.kt | 14 + .../ui/composable/ChatRoomDetailScreen.kt | 72 +- .../composable/CuratedSuggestionListScreen.kt | 690 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 17 + .../routes/CuratedSuggestionListRoute.kt | 19 + .../model/CuratedSuggestionListViewModel.kt | 257 +++++++ .../state/CuratedSuggestionListUIState.kt | 40 + .../compose/nostr/CuratedSuggestionTest.kt | 191 +++++ .../nostr/curated/CuratedEntryEventTest.kt | 382 ++++++++++ .../CuratedSuggestionListScreenJvmTest.kt | 306 ++++++++ .../GroupNostrProfileSectionJvmTest.kt | 29 + .../CuratedSuggestionListViewModelJvmTest.kt | 335 +++++++++ docs/scripts/m3-title-case.py | 2 + 20 files changed, 2988 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1d34050d..f9412875 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -11,7 +11,7 @@ actual 334 strings sat inside composables. The launcher label is a separate resource, in androidMain/res/values/strings.xml, - which is what AndroidManifest.xml points at. It already said "Mantra". + which is what AndroidManifest.xml points at. Both now say "Mantra". Externalising the 334 is the rest of phase 4 in docs/material-design-conformance.md. They land here. @@ -249,7 +249,7 @@ The ceremony was abandoned. The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum. The group has a shared key. - The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good. + The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good. These are your keys. Keep them safe so they can keep unlocking this profile and its coins, even when you lose or change your phone. This artifact has no version for a translation to be of. This artifact has no version for a chapter to attach to. @@ -570,6 +570,21 @@ Content must be text. The content isn't a profile. A kind 0 carries a JSON object of profile fields. This schema can't be signed as it is: + View suggestions + Suggestions + Everything anyone has suggested to this list, newest first. Only the group can take one up into the list. + 1 suggestion + %1$s suggestions + %1$s curated + Read from %1$s + Asking the list's relays… + Nobody has suggested anything to this list yet. + Ask again + Curated + Suggested by %1$s on %2$s + Taken up by the group into the list. + Copy the suggestion + Copied the suggestion A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec. An npub is a public key. Mantra needs the secret key, the nsec, to sign as you. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt index 61775857..33c48367 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt @@ -77,6 +77,17 @@ interface NostrEventDao { query: RoomRawQuery ): List + /** + * The same query, watched. Observed tables named explicitly because a raw query cannot + * infer them: the events themselves, and the profiles joined onto them, so a row whose + * author's kind 0 arrives after the row does re-emits with the name filled in. + */ + @Transaction + @RawQuery(observedEntities = [NostrEvent::class, press.mantra.compose.database.model.Profile::class]) + fun observeNostrEventsMatchingFilter( + query: RoomRawQuery + ): Flow> + @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") suspend fun getNostrEvents( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt index 007681a1..8b514f6b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt @@ -478,6 +478,17 @@ data class NostrEvent( } } + /** The row as the event it was stored from, for readers written against the wire shape. */ + fun toEvent(): Event = Event( + id = id, + pubKey = pubKey, + createdAt = createdAt.epochSeconds, + kind = kind, + tags = tags, + content = content, + sig = sig, + ) + fun toTextNoteEvent(): TextNoteEvent { return TextNoteEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index baa14952..d9218c2b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -738,6 +738,12 @@ class DatabaseNostrRepository( } } + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { + return database.nostrEventDao().observeNostrEventsMatchingFilter( + NostrEventFilterQuery.build(synchronizationFilter) + ) + } + override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt new file mode 100644 index 00000000..67fcdb00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt @@ -0,0 +1,115 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.extensions.shortened +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import kotlin.time.Instant + +/** + * One suggestion to a list the group curates, as somebody published it and as + * this device has read it. + * + * The schema is the group asking; these are the answers. Anyone may publish one + * -- that is the point of a list -- so unlike everything else on the group's + * screen a suggestion is not the group's word and was never signed in the room. + * It is a `NostrEvent` off a relay, signature-checked on the way in like every + * other, and then checked against the schema it replies to, because a relay is + * an untrusted place and an entry that does not satisfy the schema is not shown + * with the bad parts blanked out. See [CuratedEntryEvent]. + * + * ### The queue and the list + * + * The NIP's words: the canonical entries are the list, and the suggestions are + * its queue. This is the queue. A suggestion is marked [isCurated] when a + * canonical entry the *group* signed shares its identifier -- the coordinate + * both land on -- which is how a reader tells what the group has already taken + * up from what is still waiting. Only the group's own canonical entries count: + * curation is the one power a schema does not delegate, and a kind 31890 from + * anybody else is somebody else's list. + */ +data class CuratedSuggestion( + /** The entry, keyed by the schema's field names. */ + val entry: CuratedEntry, + /** The event as it was stored, for the id, the tags, and copying it somewhere. */ + val event: NostrEvent, + /** The suggester's profile where one has arrived, else null. */ + val suggester: Profile?, + /** Whether a canonical entry by the group shares this suggestion's identifier. */ + val isCurated: Boolean, +) { + val id: String get() = event.id + + val suggestedAt: Instant get() = event.createdAt + + /** + * What to call the suggester: their name where a profile has arrived and is + * a real one, else a short key. A placeholder profile is the one the indexer + * writes the moment a pubkey is first seen, and it is named "LOADING..." -- + * which is not a name. + */ + fun suggesterName(): String = + suggester?.takeUnless { it.createdAt == GENESIS_AT }?.humanReadableNameOrPubkey() + ?: entry.pubKey.shortened() + + companion object { + /** + * The queue of [schema], read off [events], newest first. + * + * Two readings over the same rows. Every kind 31888 that satisfies the + * schema and replies to its coordinate is a suggestion; every kind 31890 + * that does the same *and* is signed by the group is a canonical entry. + * Both kinds are addressable, so among events sharing a coordinate the + * newest is the entry and the rest are its history -- the client-side + * merge that realises an edit, since different relays hold different + * latest versions. Then the canonical identifiers mark the suggestions. + * + * Newest first because that is the order a queue is read in, and the id + * breaking every tie so two devices reading the same events in different + * orders show the same thing. + */ + fun queueOf(schema: GroupCuratedSchema, events: List): List { + val coordinate = schema.coordinate() + + val entries = events.mapNotNull { local -> + val event = local.nostrEvent.toEvent() + + when (event.kind) { + CuratedEntryEvent.SUGGESTION_KIND -> + CuratedEntryEvent.suggestion(event, schema.schema, coordinate) + CuratedEntryEvent.CANONICAL_KIND -> + CuratedEntryEvent.canonical(event, schema.schema, coordinate, curator = schema.publicKey) + else -> null + }?.let { entry -> local to entry } + } + .groupBy { (_, entry) -> entry.coordinate() } + .values + .map { versions -> + versions.maxWith(compareBy({ (_, entry) -> entry.createdAt }, { (_, entry) -> entry.id })) + } + + val curatedIdentifiers = entries + .filter { (_, entry) -> entry.isCanonical } + .map { (_, entry) -> entry.identifier } + .toSet() + + return entries + .filterNot { (_, entry) -> entry.isCanonical } + .map { (local, entry) -> + CuratedSuggestion( + entry = entry, + event = local.nostrEvent, + suggester = local.profile, + isCurated = entry.identifier in curatedIdentifiers, + ) + } + .sortedWith( + compareByDescending { it.entry.createdAt } + .thenByDescending { it.entry.id } + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt index 40efc723..1d6727d8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt @@ -1,6 +1,7 @@ package press.mantra.compose.nostr import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -34,6 +35,9 @@ import kotlin.time.Instant * one statement here that exists to be *replied to* by strangers, so until it is * published the list it defines has no queue -- the relays it names say where * suggestions would go, and nothing yet puts the schema there for them to find. + * The queue is read all the same, by `CuratedSuggestion.queueOf` over whatever + * those relays hold for [coordinate], so a schema that reached a relay by some + * other road has its suggestions shown here. */ data class GroupCuratedSchema( /** The group's statement, whole, with the signature that makes it one. */ @@ -53,7 +57,54 @@ data class GroupCuratedSchema( /** What a suggestion's `a` root names to reply to this list. */ fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier) + /** + * What to ask a relay for to read this list's queue: the NIP's two queries, + * as one request. + * + * Everything suggested to the list is whatever anyone published in reply + * to the coordinate; the list itself is what the *group* published in reply + * to it. The `authors` on the second is redundant with the rule that only + * the curator's canonical entries count -- `CuratedSuggestion.queueOf` + * enforces that on the way in regardless -- and is there to spare the relay + * the work. Both land in the same table, and [queueReadFilter] is how they + * are read back. + */ + fun queueFilters(): Array = arrayOf( + SynchronizationFilter( + kinds = arrayOf(schema.suggestionKind), + tags = mapOf(COORDINATE_TAG to listOf(coordinate())), + limit = QUEUE_LIMIT, + ), + SynchronizationFilter( + kinds = arrayOf(CuratedSchemaEvent.CANONICAL_KIND), + authors = arrayOf(publicKey), + tags = mapOf(COORDINATE_TAG to listOf(coordinate())), + limit = QUEUE_LIMIT, + ), + ) + + /** + * What to watch locally for the same rows: both kinds, replying to the + * coordinate. One filter rather than two because the local store is a + * single table and the reading tells the kinds apart itself. + */ + fun queueReadFilter(): SynchronizationFilter = SynchronizationFilter( + kinds = arrayOf(schema.suggestionKind, CuratedSchemaEvent.CANONICAL_KIND), + tags = mapOf(COORDINATE_TAG to listOf(coordinate())), + ) + companion object { + /** The tag a reply carries its schema's coordinate in. */ + private const val COORDINATE_TAG = "a" + + /** + * How much of a queue one request asks for: the reference client's + * window, and more than any list here is likely to have. NIP-01 scopes + * a limit to the stored events a relay sends before EOSE, so a longer + * queue is read on the next visit rather than never. + */ + private const val QUEUE_LIMIT = 500 + /** * The reading of one signed event, or null when it is not one of these. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt new file mode 100644 index 00000000..340239f4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt @@ -0,0 +1,437 @@ +package press.mantra.compose.nostr.curated + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind + +/** + * Kinds 31888 and 31890: an entry in a curated list, as somebody suggested it and + * as the curator accepted it. + * + * The two are one shape. A suggestion is a reply to a schema carrying one tag + * per field the schema defines; a canonical entry is the same tags again, signed + * by the curator, with an optional pointer back at the suggestion it came from. + * They are read by one reader and checked by one verifier, and only the kind, + * the signer and the pointer tell them apart -- which is what lets the list + * stand on its own if a suggester deletes their entry. + * + * This is the other half of [CuratedSchemaEvent]: that is what a list *asks* + * for, this is what it *gets*. Ported from the reference module's `verifyEntry` + * rule for rule, so that an entry bitcoin.mov accepts is one this app accepts + * and an entry it refuses is refused here for the same reason. + * + * ### Rejected, not repaired + * + * The NIP is explicit: a client MUST verify an entry against its schema and + * MUST reject one that does not satisfy it, rather than showing it with the + * offending parts blanked out. Relays carry malformed, partial and hostile + * events from other applications, and a half-parsed entry is worse than a + * missing one. So [suggestion] and [canonical] return the entry whole or return + * null, and [verifySuggestion] and [verifyCanonical] say why. + */ +object CuratedEntryEvent { + /** The kind anyone publishes a suggestion under. */ + const val SUGGESTION_KIND: Kind = CuratedSchemaEvent.SUGGESTION_KIND + + /** The kind the curator republishes an accepted suggestion as. */ + const val CANONICAL_KIND: Kind = CuratedSchemaEvent.CANONICAL_KIND + + private const val TAG_ADDRESS = "a" + private const val TAG_EVENT = "e" + private const val TAG_IDENTIFIER = "d" + private const val TAG_TITLE = "title" + + /** What every schema coordinate starts with, which is how a root is told from a source. */ + private val SCHEMA_PREFIX = "${CuratedSchemaEvent.KIND}:" + + private val SUGGESTION_PREFIX = "$SUGGESTION_KIND:" + + /** + * The entry [event] carries, checked against [schema] as a suggestion to the + * list at [coordinate], or null when it is not one a client should show. + */ + fun suggestion(event: Event, schema: CuratedSchema, coordinate: String): CuratedEntry? = + if (verifySuggestion(event, schema, coordinate).isEmpty()) read(event, schema) else null + + /** + * The entry [event] carries, checked against [schema] as the list's own at + * [coordinate], or null. [curator] is the pubkey that signed the schema, and + * the only one this will accept an author of. + */ + fun canonical(event: Event, schema: CuratedSchema, coordinate: String, curator: HexKey): CuratedEntry? = + if (verifyCanonical(event, schema, coordinate, curator).isEmpty()) read(event, schema) else null + + /** + * The entry as the tags say it, whether or not it satisfies the schema. + * + * Split from [suggestion] and [canonical] for the same reason + * [CuratedSchemaEvent.read] is split from its parse: a reader that wants to + * show *why* -- a test, a form -- verifies separately. Everything here is + * keyed by the schema's field names rather than by tag, because a field is + * what a form and a screen know an entry by; the tag is where it was kept. + */ + fun read(event: Event, schema: CuratedSchema): CuratedEntry { + val values = schema.fields + .associate { field -> field.name to valuesOf(field, event.tags, event.content) } + .filterValues { it.isNotEmpty() } + + return CuratedEntry( + kind = event.kind, + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + identifier = first(event.tags, TAG_IDENTIFIER)?.trim() ?: "", + title = first(event.tags, TAG_TITLE)?.trim() ?: "", + values = values, + source = if (event.kind == CANONICAL_KIND) source(event.tags) else null, + ) + } + + /** + * Every reason [event] is not a suggestion to the list at [coordinate], or + * nothing. + * + * The NIP's seven rules in the NIP's order: the kind is the suggestion kind; + * every required field is present; no field without `repeat` appears twice; + * every value matches its field's type and config; every `require-any` group + * is met; the `a` root names the schema; and the author is somebody the + * schema's visibility lets suggest. + */ + fun verifySuggestion(event: Event, schema: CuratedSchema, coordinate: String): List = + verifyEntry(event, schema, coordinate, expectedKind = schema.suggestionKind) + + /** + * Every reason [event] is not the list's own entry at [coordinate], or nothing. + * + * Everything [verifySuggestion] checks, for kind 31890, and two rules on top. + * The author must be [curator]: curation is the one power a schema does not + * delegate, and `visibility` has no bearing on it. And a source reference, + * when there is one, must be a well-formed suggestion coordinate -- a + * malformed one is worse than none, since it credits the wrong person or + * points at an entry nobody suggested. + */ + fun verifyCanonical( + event: Event, + schema: CuratedSchema, + coordinate: String, + curator: HexKey, + ): List = buildList { + addAll(verifyEntry(event, schema, coordinate, expectedKind = CANONICAL_KIND)) + + if (event.pubKey != curator) { + add(CuratedEntryProblem(FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator)) + } + + val source = firstAddress(event.tags, SUGGESTION_PREFIX) + if (source != null && CuratedCoordinate.parse(source) == null) { + add(CuratedEntryProblem(FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed)) + } + } + + /** + * One value against its field's type and config, or the first thing wrong + * with it. + * + * `max` is a bound on numeric types and a length on everything else, and a + * numeric field returns before the length check so the two never meet. + * A pattern the platform cannot compile counts as not matched: the schema + * asked for a check this reader cannot make, and an entry it cannot check + * is one it cannot vouch for. + */ + fun checkValue(field: CuratedField, value: String): CuratedEntryProblem.Reason? { + val trimmed = value.trim() + val config = field.config + + if (trimmed.isEmpty()) return CuratedEntryProblem.Reason.Empty + + when (field.type) { + CuratedFieldType.Text, CuratedFieldType.LongText -> Unit + + CuratedFieldType.Token -> + if (trimmed.any { it.isWhitespace() }) return CuratedEntryProblem.Reason.NotOneWord + + CuratedFieldType.Url -> + if (!isSafeUrl(trimmed, httpsOnly = config.https == true)) { + return if (config.https == true) { + CuratedEntryProblem.Reason.NotHttps + } else { + CuratedEntryProblem.Reason.NotAUrl + } + } + + CuratedFieldType.Image -> + if (!isSafeUrl(trimmed, httpsOnly = true)) return CuratedEntryProblem.Reason.NotHttps + + CuratedFieldType.Enum -> + if (config.options?.contains(trimmed) != true) return CuratedEntryProblem.Reason.NotAnOption + + CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> { + val number = trimmed.toWholeNumberOrNull() ?: return CuratedEntryProblem.Reason.NotAWholeNumber + if (field.type == CuratedFieldType.Duration && number <= 0) return CuratedEntryProblem.Reason.NotPositive + config.min?.let { if (number < it) return CuratedEntryProblem.Reason.BelowMinimum } + config.max?.let { if (number > it) return CuratedEntryProblem.Reason.AboveMaximum } + // A numeric `max` is a bound, not a length cap. + return null + } + } + + config.max?.let { if (trimmed.length > it) return CuratedEntryProblem.Reason.TooLong } + + config.pattern?.let { pattern -> + val whole = runCatching { Regex("^(?:$pattern)$") }.getOrNull() + if (whole == null || !whole.matches(trimmed)) return CuratedEntryProblem.Reason.PatternMismatch + } + + return null + } + + /** + * Every value [field] holds in an event: its tags, or the content body. + * + * A field with a marker matches only tags carrying that marker in third + * position; one without matches every tag of its name, whatever the third + * element says. Blank values are not values. + */ + fun valuesOf(field: CuratedField, tags: Array>, content: String): List { + val tag = field.tag() + if (tag == CuratedFieldConfig.CONTENT_TAG) { + return content.trim().takeIf { it.isNotEmpty() }?.let { listOf(it) } ?: emptyList() + } + + val marker = field.config.marker + return tags + .filter { it.size > 1 && it[0] == tag && it[1].isNotBlank() } + .filter { marker == null || it.getOrNull(2) == marker } + .map { it[1] } + } + + /** + * Where a canonical entry says it came from, or null for one nobody suggested. + * + * The coordinate is the `a` tag with the suggestion kind's prefix, which is + * what tells it from the schema root; the id is the first `e` tag, pinning + * the exact version the curator reviewed, and may be absent. + */ + fun source(tags: Array>): CuratedSuggestionRef? { + val address = firstAddress(tags, SUGGESTION_PREFIX) ?: return null + val coordinate = CuratedCoordinate.parse(address) ?: return null + + return CuratedSuggestionRef( + id = first(tags, TAG_EVENT)?.trim()?.ifEmpty { null }, + coordinate = address, + pubKey = coordinate.pubKey, + ) + } + + /** An http or https URL with a host, or with [httpsOnly] an https one. Nothing else reaches a link. */ + fun isSafeUrl(value: String, httpsOnly: Boolean = false): Boolean = + if (httpsOnly) CuratedSchemaEvent.isHttpsUrl(value) else HTTP_URL.matches(value.trim()) + + private val HTTP_URL = Regex("^https?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE) + + /** The problems a suggestion and a canonical entry share. */ + private fun verifyEntry( + event: Event, + schema: CuratedSchema, + coordinate: String, + expectedKind: Kind, + ): List = buildList { + if (event.kind != expectedKind) { + add(CuratedEntryProblem(FIELD_KIND, CuratedEntryProblem.Reason.WrongKind)) + } + + // The root is required whenever the schema has a coordinate to reply to, + // which here is always: a group's schema is signed before anything can + // reply to it. Only schema coordinates count; an entry carries `a` tags + // for other reasons too, the source pointer among them. + val roots = event.tags + .filter { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(SCHEMA_PREFIX) } + .map { it[1] } + if (roots.isEmpty()) { + add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply)) + } else if (coordinate !in roots) { + add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere)) + } + + for (field in schema.fields) { + val values = valuesOf(field, event.tags, event.content) + + if (values.isEmpty()) { + if (field.isRequired) add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Missing)) + continue + } + + if (values.size > 1 && field.config.repeat != true) { + add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Repeated)) + } + + // One problem per field is enough. + values.firstNotNullOfOrNull { checkValue(field, it) } + ?.let { add(CuratedEntryProblem(field.name, it)) } + } + + for (group in schema.requireAny) { + val satisfied = group.any { name -> + schema.fields.firstOrNull { it.name == name } + ?.let { valuesOf(it, event.tags, event.content).isNotEmpty() } + ?: false + } + if (!satisfied) { + add(CuratedEntryProblem(group.first(), CuratedEntryProblem.Reason.RequireAnyUnmet)) + } + } + + if (!schema.canSuggest(event.pubKey, coordinate)) { + add(CuratedEntryProblem(FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)) + } + } + + /** + * Whether [pubKey] may suggest to this list, at [coordinate]. + * + * Everyone on a public list; the curator and the `p` tags on the other two. + * The curator is read off the coordinate rather than passed, because a + * [CuratedSchema] does not know who signed it and the coordinate does. + */ + private fun CuratedSchema.canSuggest(pubKey: HexKey, coordinate: String): Boolean { + if (visibility == CuratedVisibility.Public) return true + if (CuratedCoordinate.parse(coordinate)?.pubKey == pubKey) return true + return pubKey in suggesters + } + + private fun first(tags: Array>, name: String): String? = + tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1) + + private fun firstAddress(tags: Array>, prefix: String): String? = + tags.firstOrNull { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(prefix) }?.get(1) + + /** An optionally signed run of digits and nothing else, as the reference reads one. */ + private fun String.toWholeNumberOrNull(): Long? = + takeIf { WHOLE_NUMBER.matches(it) }?.toLongOrNull() + + private val WHOLE_NUMBER = Regex("^-?\\d+$") + + /** The names problems are filed under when they are about the entry rather than a field. */ + const val FIELD_KIND = "kind" + const val FIELD_SCHEMA = "schema" + const val FIELD_VISIBILITY = "visibility" + const val FIELD_CURATOR = "curator" + const val FIELD_SOURCE = "source" +} + +/** + * One entry in a curated list, read off a kind 31888 or 31890 event. + * + * [values] is keyed by the schema's field names, each holding every value the + * entry carries for that field in tag order -- more than one only for a field + * the schema lets repeat. A field the entry has nothing for is absent rather + * than empty. [identifier] and [title] are also in there under whatever the + * schema calls those fields; they are lifted out because every list has them + * and every screen wants them. + */ +data class CuratedEntry( + val kind: Kind, + val id: HexKey, + val pubKey: HexKey, + /** Unix seconds, as the event carries it. */ + val createdAt: Long, + /** The `d` tag, which with [kind] and [pubKey] is the entry's coordinate. */ + val identifier: String, + val title: String, + val values: Map>, + /** For a canonical entry, the suggestion it came from, or null when nobody suggested it. */ + val source: CuratedSuggestionRef? = null, +) { + val isCanonical: Boolean get() = kind == CuratedEntryEvent.CANONICAL_KIND + + /** `kind:pubkey:d`. Two events sharing one are versions of one entry. */ + fun coordinate(): String = CuratedCoordinate(kind, pubKey, identifier).toValue() + + /** The first value of [fieldName], or null when the entry has none. */ + fun value(fieldName: String): String? = values[fieldName]?.firstOrNull() +} + +/** Where a canonical entry came from: the suggestion the curator accepted. */ +data class CuratedSuggestionRef( + /** The exact version curated, or null when the curator did not pin one. */ + val id: HexKey?, + /** `31888::`, which follows the suggester's later edits. */ + val coordinate: String, + /** The suggester, credited. */ + val pubKey: HexKey, +) + +/** + * One reason an entry does not satisfy its schema: which field, and what about + * it. [field] is the field's name, or one of the [CuratedEntryEvent] `FIELD_` + * names for a rule about the entry as a whole; a `require-any` group is filed + * under its first field, so a form can show it inline. + */ +data class CuratedEntryProblem(val field: String, val reason: Reason) { + enum class Reason { + /** Not the kind the schema said suggestions come as, or not the canonical kind. */ + WrongKind, + + /** No `a` root naming any schema. */ + NotAReply, + + /** An `a` root naming some other schema. */ + RepliesElsewhere, + + /** A required field with no value. */ + Missing, + + /** A field without `repeat` with more than one. */ + Repeated, + + Empty, + NotOneWord, + NotAUrl, + NotHttps, + NotAnOption, + NotAWholeNumber, + NotPositive, + BelowMinimum, + AboveMaximum, + TooLong, + PatternMismatch, + + /** None of a `require-any` group's fields is present. */ + RequireAnyUnmet, + + /** The author may not suggest to a closed or private list. */ + NotPermitted, + + /** A canonical entry signed by somebody other than the schema's author. */ + NotTheCurator, + + /** A source pointer that is not a suggestion coordinate. */ + SourceMalformed, + } +} + +/** + * An addressable coordinate, `::`, split the one way the NIP + * allows: on the first two colons only. A `d` may itself contain colons -- + * `imdb:tt2821314` does -- so splitting on every one mangles the identifier and + * miscounts the parts. + */ +data class CuratedCoordinate(val kind: Kind, val pubKey: HexKey, val identifier: String) { + fun toValue(): String = "$kind:$pubKey:$identifier" + + companion object { + private val SHAPE = Regex("^(\\d{1,5}):([0-9a-fA-F]{64}):(.+)$") + + /** The three parts, or null for anything that is not a coordinate with a non-empty `d`. */ + fun parse(value: String): CuratedCoordinate? { + val match = SHAPE.matchEntire(value.trim()) ?: return null + val (kind, pubKey, identifier) = match.destructured + + return CuratedCoordinate( + kind = kind.toInt(), + pubKey = pubKey.lowercase(), + identifier = identifier, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 2ea083e5..cc6f7003 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -175,6 +175,16 @@ interface NostrRepository { suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> + /** + * Every stored event matching [synchronizationFilter], newest first, for as long as + * the caller collects. + * + * The filter is applied whole -- see `NostrEventFilterQuery` -- where [observeNostrFeed] + * recognises a handful of shapes and falls back to text notes for the rest. A reader + * that queued the same filter against a relay watches the answer arrive through this. + */ + suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> + suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean = true @@ -385,6 +395,10 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { + TODO("Not yet implemented") + } + override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 161f8bf7..0f2483a7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.Ballot import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.DataObject @@ -79,6 +80,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute @@ -109,6 +111,7 @@ import mantra.composeapp.generated.resources.a_reply import mantra.composeapp.generated.resources.curated_schemas import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet import mantra.composeapp.generated.resources.add_schema +import mantra.composeapp.generated.resources.view_suggestions import mantra.composeapp.generated.resources.fields_colon import mantra.composeapp.generated.resources.propose_event import mantra.composeapp.generated.resources.posted_on @@ -561,18 +564,48 @@ fun ChatRoomDetailScreen( items = chatRoomDetailUIState.groupCuratedSchemas, key = { curated -> curated.signedEvent.id } ) { curated -> - GroupCuratedSchemaCard( - curatedSchema = curated, - // Into the editor for a member who - // could propose a revision, and a plain - // card for one who could not: the same - // gate as every button in this block, - // applied to the card because each - // schema is edited on its own. - onEdit = if (chatRoomDetailUIState.canEditNostrProfile) { - { + // The card and its queue as one item, so + // the button stays under the schema it is + // about however the list is arranged. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupCuratedSchemaCard( + curatedSchema = curated, + // Into the editor for a member who + // could propose a revision, and a plain + // card for one who could not: the same + // gate as every button in this block, + // applied to the card because each + // schema is edited on its own. + onEdit = if (chatRoomDetailUIState.canEditNostrProfile) { + { + onNavigateToRoute.invoke( + EditGroupCuratedSchemaRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = curated.identifier + ) + ) + } + } else { + null + } + ) + + // Under every schema and behind no + // gate, because the queue is the one + // thing here the group did not write: + // a schema exists to be answered by + // strangers, and reading the answers + // takes no share of the key. Per card + // because each list has its own queue. + TextButton( + onClick = { onNavigateToRoute.invoke( - EditGroupCuratedSchemaRoute( + CuratedSuggestionListRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, relayHint = relayHint, @@ -580,10 +613,21 @@ fun ChatRoomDetailScreen( ) ) } - } else { - null + ) { + Icon( + Icons.Default.Inbox, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.view_suggestions)) } - ) + } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt new file mode 100644 index 00000000..b9378833 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt @@ -0,0 +1,690 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.ask_again +import mantra.composeapp.generated.resources.asking_the_list_s_relays +import mantra.composeapp.generated.resources.copied_the_suggestion +import mantra.composeapp.generated.resources.copy_the_suggestion +import mantra.composeapp.generated.resources.curated +import mantra.composeapp.generated.resources.everything_anyone_has_suggested_to_this_list +import mantra.composeapp.generated.resources.n_curated +import mantra.composeapp.generated.resources.n_suggestions +import mantra.composeapp.generated.resources.nobody_has_suggested_anything_to_this_list_yet +import mantra.composeapp.generated.resources.one_suggestion +import mantra.composeapp.generated.resources.read_from_relays +import mantra.composeapp.generated.resources.suggested_by_on +import mantra.composeapp.generated.resources.suggestions +import mantra.composeapp.generated.resources.taken_up_by_the_group_into_the_list +import mantra.composeapp.generated.resources.the_signed_event +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.CuratedSuggestionListViewModel +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.time.Instant + +/** + * Everything anyone has suggested to one list the group curates. + * + * The group's screen shows the schema: what the list asks for. This is what it + * has been answered with -- one row per suggestion, newest first, whoever + * signed it -- which is the view a member wants before the group takes + * anything up: to see what is waiting, and what has already been taken. + * + * Read from the relays the schema names, and the header says which, because a + * queue read from the wrong relays and a list nobody has written to look the + * same from here. A suggestion that does not satisfy the schema is not on this + * screen at all; see `CuratedSuggestion`. + * + * A row opens a sheet with the whole entry, field by field, and the event it + * came as -- the thing a member can hand to somebody else to check, and the + * thing a canonical entry is made from. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CuratedSuggestionListScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String, + initialCuratedSuggestionListUIState: CuratedSuggestionListUIState = + CuratedSuggestionListUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + onNavigateBack: () -> Unit, +) { + val curatedSuggestionListViewModel: CuratedSuggestionListViewModel = viewModel( + factory = CuratedSuggestionListViewModel.factory( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + identifier = identifier, + initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ) + ) + + LaunchedEffect(true) { + if (initialCuratedSuggestionListUIState == CuratedSuggestionListUIState.Loading) { + curatedSuggestionListViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.suggestions), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + ScreenStateTransition(curatedSuggestionListViewModel.curatedSuggestionListUIState) { uiState -> + when (val state = uiState) { + is CuratedSuggestionListUIState.Error -> { + // Nothing to retry: the room and the identifier came from a + // navigation argument, and reading them again with the same + // ones fails the same way. + ErrorState( + modifier = Modifier.padding(padding), + message = state.message, + onRetry = null + ) + } + + CuratedSuggestionListUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + LoadingDataIndicator(fillScreen = false) + } + } + + is CuratedSuggestionListUIState.Loaded -> { + // The id rather than the row, so the sheet follows an edit + // that replaces the row under it, and survives a rotation. + var openSuggestionId by rememberSaveable { mutableStateOf(null) } + + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + CuratedQueueHeader(state = state) + } + + if (state.suggestions.isEmpty()) { + item { + if (state.isAsking) { + LoadingDataIndicator( + fillScreen = false, + text = stringResource(Res.string.asking_the_list_s_relays) + ) + } else { + // Empty is a fact about the relays that were + // asked, and asking again is the one thing a + // member can do about it from here. + EmptyState( + message = stringResource( + Res.string.nobody_has_suggested_anything_to_this_list_yet + ), + action = { + TextButton( + onClick = { curatedSuggestionListViewModel.askAgain() } + ) { + Text(stringResource(Res.string.ask_again)) + } + } + ) + } + } + } else { + items( + items = state.suggestions, + key = { suggestion -> suggestion.id } + ) { suggestion -> + CuratedSuggestionCard( + suggestion = suggestion, + schema = state.schema.schema, + onOpen = { openSuggestionId = suggestion.id } + ) + } + } + } + + openSuggestionId + ?.let { id -> state.suggestions.firstOrNull { it.id == id } } + ?.let { suggestion -> + CuratedSuggestionSheet( + suggestion = suggestion, + schema = state.schema.schema, + onDismiss = { openSuggestionId = null } + ) + } + } + } + } + } +} + +/** + * What the queue is, in numbers, and where it was read from. + * + * The list is named the way its card on the group's screen names it -- the + * domain where it claims one, else the name -- so a member arriving from that + * card finds the same word. The counts are the reference client's line: how + * many are waiting, and how many of those the group has already taken up. + */ +@Composable +private fun CuratedQueueHeader(state: CuratedSuggestionListUIState.Loaded) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { + Text( + text = state.schema.schema.displayName(), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(Res.string.everything_anyone_has_suggested_to_this_list), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = listOfNotNull( + when (state.suggestions.size) { + 1 -> stringResource(Res.string.one_suggestion) + else -> stringResource(Res.string.n_suggestions, state.suggestions.size) + }, + state.curatedCount.takeIf { it > 0 }?.let { stringResource(Res.string.n_curated, it) } + ).joinToString(" · "), + style = MaterialTheme.typography.labelMedium + ) + + if (state.relays.isNotEmpty()) { + Text( + text = stringResource( + Res.string.read_from_relays, + state.relays.joinToString { relay -> + RelayUrlNormalizer.normalizeOrNull(relay)?.displayUrl() ?: relay + } + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * One suggestion, as a row somebody is scanning. + * + * The title, because every list has one; a glance at the fields that say what + * the entry *is* -- the type, the year, the director -- and not the ones that + * say where to find it, since a URL on a row is a line of noise and the sheet + * has them; the body where the list has one; and who suggested it, when. The + * avatar is the suggester's, tinted from their key like every avatar here, so + * two suggestions by one stranger read as one stranger. + * + * "Curated" on the row is the one thing on it a member might act on: it says + * the group already took this up, and re-curating it would revise the entry + * rather than add one. + */ +@Composable +private fun CuratedSuggestionCard( + suggestion: CuratedSuggestion, + schema: CuratedSchema, + onOpen: () -> Unit +) { + val entry = suggestion.entry + + Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) { + ListItem( + leadingContent = { + ProfileAvatar(publicKey = entry.pubKey, profile = suggestion.suggester) + }, + trailingContent = if (suggestion.isCurated) { + { CuratedMark() } + } else { + null + }, + headlineContent = { + Text( + text = entry.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + glanceOf(entry, schema)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + bodyOf(entry, schema)?.let { + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.suggested_by_on, + suggestion.suggesterName(), + suggestion.suggestedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +@Composable +private fun CuratedMark() { + Row(verticalAlignment = Alignment.CenterVertically) { + // The word beside it says it, so the icon says nothing twice. + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource(Res.string.curated), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } +} + +/** + * The fields worth a glance on a row, as `Label: value`, or null when the entry + * has none of them. + * + * The fields a form prompts for, less the title (the headline), the body (its + * own line) and anything that is a link (the sheet). Labelled because a bare + * "2014 · en" says nothing to somebody who does not know the list's fields, + * and this screen is driven by a schema it has never seen before. + */ +private fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = + schema.formFields() + .filterNot { it.tag() == TITLE_TAG || it.tag() == CuratedFieldConfig.CONTENT_TAG } + .filterNot { it.type == CuratedFieldType.Url || it.type == CuratedFieldType.Image } + .mapNotNull { field -> + entry.values[field.name]?.let { values -> "${field.labelOrName()}: ${values.joinToString()}" } + } + .takeIf { it.isNotEmpty() } + ?.joinToString(" · ") + +/** The value of the field that writes to the body, or null. */ +private fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = + schema.fields.firstOrNull { it.tag() == CuratedFieldConfig.CONTENT_TAG }?.let { entry.value(it.name) } + +private const val TITLE_TAG = "title" + +/** + * The whole of one suggestion, and a way to take it somewhere else. + * + * Every field the entry has a value for, labelled as the schema labels it, so + * the sheet reads as the form would have -- and then the event itself, which + * is the only part that can be checked, and the part a canonical entry is + * built from. Byte for byte, the way the key state sheet shows the group's own + * event, and for the same reason: a prettier rendering is a different string + * to the one whose id was hashed. + */ +@Composable +private fun CuratedSuggestionSheet( + suggestion: CuratedSuggestion, + schema: CuratedSchema, + onDismiss: () -> Unit +) { + ModalBottomSheet(onDismiss = onDismiss) { + CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema) + } +} + +/** + * The sheet's body, apart from the sheet. Split out so it can be rendered and + * measured on its own, since a bottom sheet is a popup a layout test cannot + * reach into. + */ +@Composable +internal fun CuratedSuggestionSheetContent( + suggestion: CuratedSuggestion, + schema: CuratedSchema +) { + val clipboardManager = LocalClipboardManager.current + + // Read out here rather than in the click handler: both of these are + // composable, and an onClick lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val copied = stringResource(Res.string.copied_the_suggestion) + + val entry = suggestion.entry + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource( + Res.string.suggested_by_on, + suggestion.suggesterName(), + suggestion.suggestedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (suggestion.isCurated) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource(Res.string.taken_up_by_the_group_into_the_list), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + // In the schema's order, which is the order the form asked in, and + // only the fields the entry answered: a form's empty inputs are not + // worth a line each here. The title is the heading and is not said twice. + schema.fields.filterNot { it.tag() == TITLE_TAG }.forEach { field -> + entry.values[field.name]?.let { values -> + CuratedSuggestionField(field = field, values = values) + } + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + Text( + text = stringResource(Res.string.the_signed_event), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + val json = suggestion.event.toEvent().toJson() + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = json, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding) + ) + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + FilledTonalButton( + onClick = { + clipboardManager.setText(AnnotatedString(json)) + notify(copied) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.copy_the_suggestion)) + } + } +} + +/** + * One field of an entry: the schema's label over the value, or the values one + * under another for a field that repeats. Monospaced where the value is a + * thing rather than prose -- a token, a link -- because those are compared and + * copied rather than read. + */ +@Composable +private fun CuratedSuggestionField(field: CuratedField, values: List) { + val isMonospaced = when (field.type) { + CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true + else -> false + } + + Text( + text = field.labelOrName(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = values.joinToString("\n"), + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isMonospaced) FontFamily.Monospace else null, + // Stated rather than inherited: the label above is deliberately quieter, + // and the value is the thing that was come for. + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) +} + +@ConformancePreviews +@Composable +private fun CuratedSuggestionListScreenPreview() { + val chatRoomId = "d4c3b2a1".repeat(8) + val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"), + CuratedField( + "description", + CuratedFieldType.LongText, + isRequired = false, + label = "Why", + config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) + ), + ), + relays = listOf("wss://relay.one.example") + ) + ) + + fun suggestion(title: String, createdAt: Long, isCurated: Boolean) = CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = createdAt.toString().padStart(64, 'a'), + pubKey = suggester, + createdAt = createdAt, + identifier = title.lowercase().replace(' ', '-'), + title = title, + values = mapOf( + "title" to listOf(title), + "year" to listOf("2014"), + "type" to listOf("documentary"), + "description" to listOf("Follows the early Bitcoin community from 2011 onward."), + ) + ), + event = NostrEvent( + id = createdAt.toString().padStart(64, 'a'), + pubKey = suggester, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = emptyArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + suggester = null, + isCurated = isCurated + ) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + CuratedSuggestionListScreen( + activeUserPublicKey = "u".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "u".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + relays = listOf("wss://relay.one.example"), + suggestions = listOf( + suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), + suggestion("Magic Money", 1_700_000_000, isCurated = false), + ) + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateBack = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 3e0753a9..560951b9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen import press.mantra.compose.ui.composable.AddGroupPostScreen import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen +import press.mantra.compose.ui.composable.CuratedSuggestionListScreen import press.mantra.compose.ui.composable.ProposeGroupEventScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen @@ -132,6 +133,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute @@ -1112,6 +1114,21 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + CuratedSuggestionListScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + identifier = route.identifier, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt new file mode 100644 index 00000000..9659aecb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The queue of one list the group curates: everything anyone has suggested to + * the schema at [identifier], read off the relays the schema names. + * + * Named by the schema's `d` for the reason `EditGroupCuratedSchemaRoute` is: + * the coordinate is what every suggestion replies to, and the group re-signing + * the schema changes the event without changing the queue. + */ +@Serializable +data class CuratedSuggestionListRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String?, + val identifier: String +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt new file mode 100644 index 00000000..cc981945 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt @@ -0,0 +1,257 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.time.Duration.Companion.seconds + +/** + * The queue of one list the group curates, read off the relays the schema + * names. + * + * A pull, the way every one-off read in this app is: a request row per relay + * for the sync pump to drain, and a watch on the local table for what comes + * back. Nothing here talks to a relay itself. What the screen shows is + * therefore what this device has *stored* for the coordinate, which is also + * what it shows with no network at all -- and what it showed last time, until + * the relays answer again. + * + * ### Where the queue is read from + * + * The schema's `relay` tags, first. They are signed into the schema for exactly + * this: a client that finds the list anywhere knows where its replies live and + * cannot be sent elsewhere by an unsigned config. A schema that names none -- + * the NIP allows it and says a client may then pick its own -- is read from + * the group's general relay list, which is where the group said it publishes, + * and failing that from this build's default. The screen says which. + * + * ### What "still looking" means + * + * The pump marks a request sent when it goes out and processed when an event + * comes back, and a relay holding nothing for the coordinate answers with an + * EOSE the pump does not record. So there is no signal for "asked and empty", + * and the screen would otherwise call every list empty for the second before + * its rows arrived. [ASK_TIMEOUT] stands in: the queue is being asked until + * something lands or the wait runs out, and after that an empty queue is + * empty. The reference client does the same with a six-second timer. + */ +class CuratedSuggestionListViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + /** The `d` of the schema whose queue this is. */ + val identifier: String, + initialCuratedSuggestionListUIState: CuratedSuggestionListUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, +): ViewModel() { + + var curatedSuggestionListUIState: CuratedSuggestionListUIState by mutableStateOf( + initialCuratedSuggestionListUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + /** + * Serialises the read-modify-write on the state. The watch, the timer and + * a re-ask each update one field of it from their own coroutine, and a copy + * taken by one and written after another's is a lost update. + */ + private val stateMutex = Mutex() + + private var askTimeout: Job? = null + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find the group") + return@launch + } + + // Opened on a schema this device does not hold: nothing to ask for, + // since the coordinate is the group's key and the identifier and + // half of that came from a screen that no longer agrees. + val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier } + if (schema == null) { + curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find that schema") + return@launch + } + + val relays = relaysToAsk( + schema = schema, + general = chatRepository.groupRelayLists(chatRoomId).firstOrNull { it.set == GroupRelaySet.General }, + ) + + curatedSuggestionListUIState = CuratedSuggestionListUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema, + relays = relays, + isAsking = true, + ) + + ask(schema, relays) + watch(schema) + } + } + + /** + * Asks the relays again, for a member who believes something is missing. + * + * The one thing the watch does not do on its own: it shows what arrives, + * and this is how more is made to arrive. Idempotent on the stored rows -- + * an event already held is upserted, not duplicated. + */ + fun askAgain() { + val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return + + viewModelScope.launch(Dispatchers.IO) { + update { it.copy(isAsking = true) } + ask(loaded.schema, loaded.relays) + } + } + + private suspend fun ask(schema: GroupCuratedSchema, relays: List) { + logger.d("Asking ${relays.size} relay(s) for the queue of ${schema.coordinate()}") + + nostrRepository.queueSynchronizeNostrEvent(requestsFor(schema, relays)) + + askTimeout?.cancel() + askTimeout = viewModelScope.launch(Dispatchers.IO) { + delay(ASK_TIMEOUT) + update { it.copy(isAsking = false) } + } + } + + /** + * Watches the stored rows for the coordinate for as long as the screen + * lives, re-reading the queue whole on every change. Whole rather than + * incrementally because the reading is a merge -- newest per coordinate, + * curated by identifier -- and a merge over a delta is a different reading. + */ + private suspend fun watch(schema: GroupCuratedSchema) { + nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()) + .distinctUntilChanged() + .collect { events -> + val queue = CuratedSuggestion.queueOf(schema, events) + logger.d("${queue.size} suggestion(s) to ${schema.coordinate()} among ${events.size} stored event(s)") + + update { loaded -> withQueue(loaded, queue) } + } + } + + private suspend fun update( + change: (CuratedSuggestionListUIState.Loaded) -> CuratedSuggestionListUIState.Loaded + ) { + stateMutex.withLock { + val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return + curatedSuggestionListUIState = change(loaded) + } + } + + companion object { + private const val TAG = "CuratedSuggestionListViewModel" + + /** + * The schema's relays, else the group's, else the default. See the + * class note for why in that order. Normalised so that two spellings + * of one relay are one request, and only the group's *read* relays, + * since this is a read. + * + * [general] is the group's general relay list as the screen has it, or + * null in a room that has none of that to say. An agreed-but-empty + * list falls through to the default rather than asking nobody: the + * group withdrew where it publishes, not where its lists are read. + */ + internal fun relaysToAsk(schema: GroupCuratedSchema, general: GroupRelayList?): List { + val named = schema.schema.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it)?.url }.distinct() + if (named.isNotEmpty()) return named + + val relays = general?.takeIf { it.isAgreed }?.relays ?: GroupRelaySet.General.defaults() + + return relays.filter { it.read }.map { it.url.url }.distinct() + .ifEmpty { GroupRelaySet.General.defaults().map { it.url.url } } + } + + /** One request per relay, each carrying the NIP's two queries for the coordinate. */ + internal fun requestsFor(schema: GroupCuratedSchema, relays: List): List = + relays.map { relayUrl -> + SynchronizeNostrEventRequest( + purpose = PURPOSE, + synchronizationFilters = schema.queueFilters(), + relayURL = relayUrl, + level = 0, + ) + } + + /** + * The state once [queue] has been read off the stored rows. Something + * arriving means the relays have answered, so the asking ends there; + * nothing arriving leaves it to the timer. + */ + internal fun withQueue( + loaded: CuratedSuggestionListUIState.Loaded, + queue: List, + ): CuratedSuggestionListUIState.Loaded = loaded.copy( + suggestions = queue, + isAsking = loaded.isAsking && queue.isEmpty(), + ) + + /** The purpose the sync rows are filed under, for anything that counts them. */ + const val PURPOSE = "curated-queue" + + /** + * How long an empty queue is "being asked" before it is called empty. + * Longer than the reference client's six seconds because the request + * queues behind the pump's four subscription slots before it goes out. + */ + internal val ASK_TIMEOUT = 10.seconds + + fun factory( + chatRoomId: String, + activeUserPublicKey: HexKey, + relayHint: String?, + identifier: String, + initialCuratedSuggestionListUIState: CuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + CuratedSuggestionListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + identifier = identifier, + initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt new file mode 100644 index 00000000..5d41d950 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema + +sealed interface CuratedSuggestionListUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** The list whose queue this is, as the group last signed it. */ + val schema: GroupCuratedSchema, + /** + * Where the queue was read from: the relays the schema names, or the + * group's own where it names none. On the screen because a queue read + * from the wrong relays is an empty queue with no way to tell it from + * a list nobody has written to. + */ + val relays: List = emptyList(), + /** Everything suggested to the list, newest first. */ + val suggestions: List = emptyList(), + /** + * Whether the relays are still being asked. + * + * A pull through the sync queue has no end-of-stored-events the screen + * can see, so this is bounded by time rather than by an answer: true + * from the request going out until the first suggestion lands or the + * wait runs out. While it is true and nothing has arrived, the screen + * is looking rather than empty. + */ + val isAsking: Boolean = false, + ): CuratedSuggestionListUIState { + val curatedCount: Int get() = suggestions.count { it.isCurated } + } + + data class Error( + val message: String + ): CuratedSuggestionListUIState + + data object Loading: CuratedSuggestionListUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt new file mode 100644 index 00000000..3cd150ca --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt @@ -0,0 +1,191 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The queue of a list the group curates, read off whatever this device has + * stored for the coordinate. + * + * The rows come from a filter on kind and `a` tag, which is broader than the + * queue in three ways this reading has to close: an event that does not satisfy + * the schema, a kind 31890 signed by somebody other than the group, and an + * older version of an entry its author has since replaced. And one thing it + * has to add: whether the group has already taken a suggestion up, which is a + * canonical entry sharing its `d`. + * + * Also what to call a stranger. A profile that has arrived is a name; the + * placeholder the indexer writes on first sight is not, and a queue full of + * "LOADING..." would be a queue of nobody. + */ +class CuratedSuggestionTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val films = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField( + "watchUrl", + CuratedFieldType.Url, + isRequired = true, + config = CuratedFieldConfig(tag = "r", marker = "watch") + ), + ), + ).normalized(), + ) + + private val coordinate = films.coordinate() + + @Test + fun `the queue is every suggestion that satisfies the schema, newest first`() { + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "older", "The older one", 1_700_000_000)), + stored(suggestion(bob, "newer", "The newer one", 1_700_000_900)), + // No watch link, which the schema requires: not shown with the + // gap blanked out, not shown at all. + stored(suggestion(ada, "broken", "The broken one", 1_700_000_500, watchUrl = null)), + // Another list's reply with this one's identifier. Not this queue. + stored(suggestion(ada, "elsewhere", "Somebody else's", 1_700_000_600, root = "31889:${"e".repeat(64)}:films")), + // A text note that happens to carry the coordinate. + stored(suggestion(ada, "note", "A note", 1_700_000_700).copy(kind = 1)), + ) + ) + + assertEquals(listOf("The newer one", "The older one"), queue.map { it.entry.title }) + assertTrue(queue.none { it.isCurated }) + } + + @Test + fun `an entry its author has replaced shows once, as the newer version`() { + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)), + stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin (2014)", 1_700_000_900)), + // The same `d` under another key is another entry: coordinates + // carry the author. + stored(suggestion(bob, "rise", "Bob's rise", 1_700_000_100)), + ) + ) + + assertEquals( + listOf("The Rise and Rise of Bitcoin (2014)", "Bob's rise"), + queue.map { it.entry.title } + ) + } + + @Test + fun `a suggestion the group has taken up is marked, and the group's entry is not in the queue`() { + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)), + stored(suggestion(bob, "magic", "Magic Money", 1_700_000_100)), + stored(canonical(chatRoomId, "rise", "The Rise and Rise of Bitcoin", 1_700_000_900)), + // A kind 31890 by somebody who is not the curator is nobody's + // list, and marks nothing. + stored(canonical(bob, "magic", "Magic Money", 1_700_000_950)), + ) + ) + + assertEquals(listOf("Magic Money", "The Rise and Rise of Bitcoin"), queue.map { it.entry.title }) + assertEquals( + mapOf("Magic Money" to false, "The Rise and Rise of Bitcoin" to true), + queue.associate { it.entry.title to it.isCurated } + ) + } + + @Test + fun `a suggester is named by their profile, and by their key until one arrives`() { + val named = Profile( + publicKey = ada, + displayName = "Ada", + nostrEventId = "e".repeat(64), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + val placeholder = Profile( + publicKey = bob, + displayName = "LOADING...", + nostrEventId = "e".repeat(64), + createdAt = GENESIS_AT, + ) + + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "one", "One", 1_700_000_000), profile = named), + stored(suggestion(bob, "two", "Two", 1_700_000_100), profile = placeholder), + stored(suggestion("c".repeat(64), "three", "Three", 1_700_000_200), profile = null), + ) + ) + + assertEquals( + listOf("cccccccc", "bbbbbbbb", "Ada"), + queue.map { it.suggesterName() } + ) + assertFalse(queue.any { it.suggesterName() == "LOADING..." }) + } + + private fun suggestion( + pubKey: String, + identifier: String, + title: String, + createdAt: Long, + watchUrl: String? = "https://example.com/watch", + root: String = coordinate, + ) = NostrEvent( + id = createdAt.toString().padStart(60, '0') + pubKey.take(4), + pubKey = pubKey, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = listOfNotNull( + arrayOf("d", identifier), + arrayOf("title", title), + watchUrl?.let { arrayOf("r", it, "watch") }, + arrayOf("a", root, "", "root"), + ).toTypedArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt), + ) + + private fun canonical(pubKey: String, identifier: String, title: String, createdAt: Long) = + suggestion(pubKey, identifier, title, createdAt).copy(kind = CuratedSchemaEvent.CANONICAL_KIND) + + private fun stored(event: NostrEvent, profile: Profile? = null) = + LocalNostrEvent(nostrEvent = event, profile = profile) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt new file mode 100644 index 00000000..8a9ca840 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt @@ -0,0 +1,382 @@ +package press.mantra.compose.nostr.curated + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Kinds 31888 and 31890 as the NIP writes them, read and checked. + * + * The fixtures are the NIP's own examples -- the bitcoin.mov schema, *The Rise + * and Rise of Bitcoin* as `eebb74ab…` suggested it, and the curator signing it + * off -- because the one property worth pinning is that what that site accepts + * is accepted here and what it refuses is refused here for the same reason. + * `CuratedEntryEvent` is a port of the reference module's verifier rule for + * rule, and this is where each rule gets a case. + * + * The other thing pinned is the shape of what comes back: an entry keyed by the + * schema's field names, with the tags the schema does not define ignored rather + * than kept. `director`, `i` and `lang` are on the NIP's example and on no + * field of the NIP's schema, and they are not in the reading. + */ +class CuratedEntryEventTest { + private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25" + private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + private val stranger = "1".repeat(64) + + private val coordinate = "31889:$curator:bitcoin.mov" + + /** The NIP's schema, as `CuratedSchemaEventTest` reads it. */ + private val schema: CuratedSchema = assertNotNull( + CuratedSchemaEvent.parse( + Event( + id = "a".repeat(64), + pubKey = curator, + createdAt = 1735689600, + kind = CuratedSchemaEvent.KIND, + tags = arrayOf( + arrayOf("d", "bitcoin.mov"), + arrayOf("title", "bitcoin.mov suggestion"), + arrayOf("name", "bitcoin.mov"), + arrayOf("description", "Fields for a bitcoin.mov entry."), + arrayOf("k", "31888"), + arrayOf("visibility", "public"), + arrayOf("relay", "wss://ephemeral.mantra.press"), + arrayOf("field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"), + arrayOf("field", "title", "text", "required", "", "Title", "{\"max\":200}"), + arrayOf("field", "year", "year", "optional", "", "Year", "{\"min\":1900,\"max\":2100}"), + arrayOf("field", "type", "enum", "required", "", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"), + arrayOf("field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"), + arrayOf("field", "imdbUrl", "url", "optional", "", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"), + arrayOf("field", "image", "image", "optional", "", "Poster image URL (https)", "{\"max\":500}"), + arrayOf("field", "hashtags", "token", "optional", "", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"), + arrayOf("field", "description", "longtext", "optional", "", "Description / review", "{\"tag\":\"content\",\"max\":4000}"), + arrayOf("require-any", "watchUrl", "imdbUrl"), + ), + content = "Fields for a bitcoin.mov entry.", + sig = "b".repeat(128), + ) + ) + ) + + private val review = "Follows programmer Daniel Mross and the early Bitcoin community from 2011 onward." + + /** *The Rise and Rise of Bitcoin*, as the NIP's example has it. */ + private val riseAndRise: Array> = arrayOf( + arrayOf("d", "imdb:tt2821314"), + arrayOf("title", "The Rise and Rise of Bitcoin"), + arrayOf("year", "2014"), + arrayOf("type", "documentary"), + arrayOf("director", "Nicholas Mross"), + arrayOf("r", "http://bitcoindoc.com/", "watch"), + arrayOf("r", "https://www.imdb.com/title/tt2821314/", "imdb"), + arrayOf("image", "https://upload.wikimedia.org/wikipedia/en/9/96/The_Rise_and_Rise_of_Bitcoin_%282014%29_Film_Poster.jpg"), + arrayOf("i", "imdb:tt2821314"), + arrayOf("lang", "en"), + arrayOf("t", "bitcoin"), + arrayOf("t", "documentary"), + arrayOf("a", coordinate, "wss://ephemeral.mantra.press", "root"), + arrayOf("p", curator), + arrayOf("k", "31889"), + ) + + private val suggestionId = "a809ddc2ad9c7b99e739e872b1b9e99e96ca9a3a4cb28cdf220c74324277eee8" + + @Test + fun `the NIP's suggestion satisfies the NIP's schema, and reads by field`() { + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(), schema, coordinate)) + + val entry = assertNotNull(CuratedEntryEvent.suggestion(suggestion(), schema, coordinate)) + + assertEquals(CuratedEntryEvent.SUGGESTION_KIND, entry.kind) + assertEquals(suggester, entry.pubKey) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals("The Rise and Rise of Bitcoin", entry.title) + assertEquals("31888:$suggester:imdb:tt2821314", entry.coordinate()) + assertNull(entry.source) + + // By the schema's field names, not by tag: the two `r` tags land on + // two fields, the `t` tags on one, and the content on the field that + // writes there. + assertEquals("2014", entry.value("year")) + assertEquals("documentary", entry.value("type")) + assertEquals("http://bitcoindoc.com/", entry.value("watchUrl")) + assertEquals("https://www.imdb.com/title/tt2821314/", entry.value("imdbUrl")) + assertEquals(listOf("bitcoin", "documentary"), entry.values["hashtags"]) + assertEquals(review, entry.value("description")) + + // What the schema does not define is not read, however much of it there is. + assertEquals( + setOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"), + entry.values.keys + ) + } + + @Test + fun `the minimum suggestion is enough`() { + // The doc's own minimum: nothing but what the schema insists on, and an + // IMDb link standing in for a watch link under the require-any rule. + val minimum = suggestion( + tags = arrayOf( + arrayOf("d", "bitcoin-the-end-of-money-as-we-know-it"), + arrayOf("title", "Bitcoin: The End of Money as We Know It"), + arrayOf("type", "documentary"), + arrayOf("r", "https://www.imdb.com/title/tt4654844/", "imdb"), + arrayOf("t", "bitcoin"), + arrayOf("a", coordinate, "", "root"), + ), + content = "" + ) + + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(minimum, schema, coordinate)) + assertNull(assertNotNull(CuratedEntryEvent.suggestion(minimum, schema, coordinate)).value("description")) + } + + @Test + fun `the NIP's canonical entry is the list's own, and points at where it came from`() { + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(canonical(), schema, coordinate, curator)) + + val entry = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator)) + + assertTrue(entry.isCanonical) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate()) + assertEquals( + CuratedSuggestionRef( + id = suggestionId, + coordinate = "31888:$suggester:imdb:tt2821314", + pubKey = suggester + ), + entry.source + ) + } + + @Test + fun `a canonical entry nobody suggested has no source`() { + val unprompted = canonical(tags = riseAndRise) + + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(unprompted, schema, coordinate, curator)) + assertNull(assertNotNull(CuratedEntryEvent.canonical(unprompted, schema, coordinate, curator)).source) + } + + @Test + fun `curation is not delegated`() { + // The same event, signed by the suggester: a valid suggestion and not a + // canonical entry, whatever its kind says. + val notTheCurator = canonical(pubKey = suggester) + + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator)), + CuratedEntryEvent.verifyCanonical(notTheCurator, schema, coordinate, curator) + ) + assertNull(CuratedEntryEvent.canonical(notTheCurator, schema, coordinate, curator)) + } + + @Test + fun `a source that is not a suggestion coordinate is refused`() { + val malformed = canonical( + tags = riseAndRise + arrayOf(arrayOf("a", "31888:not-a-pubkey:imdb:tt2821314", "", "mention")) + ) + + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed)), + CuratedEntryEvent.verifyCanonical(malformed, schema, coordinate, curator) + ) + } + + @Test + fun `the wrong kind is not an entry of either sort`() { + val note = suggestion(kind = 1) + + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_KIND, CuratedEntryProblem.Reason.WrongKind)), + CuratedEntryEvent.verifySuggestion(note, schema, coordinate) + ) + // And a suggestion is not a canonical entry: same tags, same author rule aside. + assertTrue( + CuratedEntryEvent.verifyCanonical(suggestion(), schema, coordinate, curator) + .any { it.reason == CuratedEntryProblem.Reason.WrongKind } + ) + } + + @Test + fun `a suggestion has to reply to this schema`() { + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("a")), schema, coordinate) + ) + + // Somebody else's list with the same identifier is somebody else's list. + val elsewhere = riseAndRise.replacing("a", "31889:$stranger:bitcoin.mov") + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = elsewhere), schema, coordinate) + ) + } + + @Test + fun `a required field has to be there`() { + assertEquals( + listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.Missing)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("title")), schema, coordinate) + ) + assertEquals( + listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.Missing)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("type")), schema, coordinate) + ) + } + + @Test + fun `a field that does not repeat appears once`() { + val twoPosters = riseAndRise + arrayOf(arrayOf("image", "https://example.com/other.jpg")) + assertEquals( + listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.Repeated)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = twoPosters), schema, coordinate) + ) + + // Where the schema says `repeat`, two is fine -- and both are read. + val twoWatchLinks = riseAndRise + arrayOf(arrayOf("r", "https://vimeo.com/1", "watch")) + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(tags = twoWatchLinks), schema, coordinate)) + assertEquals( + listOf("http://bitcoindoc.com/", "https://vimeo.com/1"), + CuratedEntryEvent.read(suggestion(tags = twoWatchLinks), schema).values["watchUrl"] + ) + } + + @Test + fun `every value has to match its field`() { + fun problemsWith(tag: String, value: String) = + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.replacing(tag, value)), schema, coordinate) + + // The things the doc lists as rejected, and why they matter. + assertEquals(listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption)), problemsWith("type", "musical")) + assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.BelowMinimum)), problemsWith("year", "1800")) + assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.AboveMaximum)), problemsWith("year", "2200")) + assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber)), problemsWith("year", "twenty fourteen")) + assertEquals(listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.NotHttps)), problemsWith("image", "http://example.com/poster.jpg")) + assertEquals(listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong)), problemsWith("title", "x".repeat(201))) + assertEquals(listOf(CuratedEntryProblem("identifier", CuratedEntryProblem.Reason.NotOneWord)), problemsWith("d", "imdb tt2821314")) + + // A `javascript:` link ends up in an href. Replacing the watch link + // leaves the IMDb one, so require-any is still met and the URL is the + // only complaint. + val scripted = riseAndRise.map { + if (it[0] == "r" && it[2] == "watch") arrayOf("r", "javascript:alert(1)", "watch") else it + }.toTypedArray() + assertEquals( + listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.NotAUrl)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = scripted), schema, coordinate) + ) + } + + @Test + fun `a require-any group needs one of its fields`() { + val noLinks = riseAndRise.filter { it[0] != "r" }.toTypedArray() + + // Filed under the group's first field, so a form can show it inline. + assertEquals( + listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.RequireAnyUnmet)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = noLinks), schema, coordinate) + ) + } + + @Test + fun `a closed list takes suggestions from the curator and the suggesters it names`() { + val closed = schema.copy(visibility = CuratedVisibility.Closed, suggesters = listOf(suggester)) + + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = suggester), closed, coordinate)) + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = curator), closed, coordinate)) + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)), + CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), closed, coordinate) + ) + + // Private is the same rule; the difference is who is shown the list, not who may write to it. + val private = closed.copy(visibility = CuratedVisibility.Private) + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)), + CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), private, coordinate) + ) + } + + @Test + fun `a value is checked by its type and its config`() { + val duration = CuratedField("runtime", CuratedFieldType.Duration, isRequired = false, config = CuratedFieldConfig(max = 36000)) + assertEquals(CuratedEntryProblem.Reason.NotPositive, CuratedEntryEvent.checkValue(duration, "0")) + assertEquals(CuratedEntryProblem.Reason.AboveMaximum, CuratedEntryEvent.checkValue(duration, "36001")) + // A numeric `max` is a bound, not a length: six digits under the bound is fine. + assertNull(CuratedEntryEvent.checkValue(duration, " 5400 ")) + + val pattern = CuratedField("code", CuratedFieldType.Token, isRequired = false, config = CuratedFieldConfig(pattern = "[a-z]{2,8}")) + assertNull(CuratedEntryEvent.checkValue(pattern, "abc")) + // End to end, not anywhere within. + assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(pattern, "abc1")) + + // A pattern this platform cannot compile is a check it cannot make, and + // an entry it cannot check is not one it vouches for. + val broken = pattern.copy(config = CuratedFieldConfig(pattern = "[unclosed")) + assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(broken, "abc")) + + assertEquals(CuratedEntryProblem.Reason.Empty, CuratedEntryEvent.checkValue(pattern, " ")) + + val strict = CuratedField("site", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(https = true)) + assertEquals(CuratedEntryProblem.Reason.NotHttps, CuratedEntryEvent.checkValue(strict, "http://example.com")) + assertNull(CuratedEntryEvent.checkValue(strict, "https://example.com")) + } + + @Test + fun `a coordinate splits on the first two colons only`() { + val parsed = assertNotNull(CuratedCoordinate.parse("31888:${suggester.uppercase()}:imdb:tt2821314")) + + assertEquals(31888, parsed.kind) + assertEquals(suggester, parsed.pubKey, "the pubkey was not lowercased") + assertEquals("imdb:tt2821314", parsed.identifier, "the identifier's own colon split it") + assertEquals("31888:$suggester:imdb:tt2821314", parsed.toValue()) + + assertNull(CuratedCoordinate.parse("31888:$suggester:"), "an empty identifier was accepted") + assertNull(CuratedCoordinate.parse("31888:not-a-pubkey:x")) + assertNull(CuratedCoordinate.parse("naddr1qqxnzd3cxqmrzv3exgmr2wfeqgs"), "a bech32 address is not a coordinate") + } + + private fun suggestion( + tags: Array> = riseAndRise, + content: String = review, + kind: Int = CuratedEntryEvent.SUGGESTION_KIND, + pubKey: String = suggester, + ): Event = Event( + id = suggestionId, + pubKey = pubKey, + createdAt = 1735689660, + kind = kind, + tags = tags, + content = content, + sig = "b".repeat(128), + ) + + /** The curator signing off on the suggestion, as the NIP's example has it. */ + private fun canonical( + tags: Array> = riseAndRise + arrayOf( + arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), + arrayOf("e", suggestionId, "", "mention"), + arrayOf("p", suggester), + ), + pubKey: String = curator, + ): Event = Event( + id = "c".repeat(64), + pubKey = pubKey, + createdAt = 1735689720, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = tags, + content = review, + sig = "b".repeat(128), + ) + + private fun Array>.without(tag: String): Array> = + filter { it[0] != tag }.toTypedArray() + + private fun Array>.replacing(tag: String, value: String): Array> = + map { if (it[0] == tag) arrayOf(tag, value) + it.drop(2) else it }.toTypedArray() +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt new file mode 100644 index 00000000..62deff65 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt @@ -0,0 +1,306 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * What the queue screen says, in each of the states it can be in. + * + * Three things are worth pinning. **The row is driven by a schema the screen + * has never seen**, so what it puts on a row is a decision -- the fields that + * say what an entry is, labelled, and not the links -- and that decision is + * checked against a schema with one of each. **"Curated" is per row**, and a + * screen that marked the wrong row would send a member to re-curate what the + * group had already taken up. And **empty is not the same as still looking**: + * the screen has both and must say which, since a queue called empty a second + * before its rows arrive teaches a member not to believe it. + */ +@OptIn(ExperimentalTestApi::class) +class CuratedSuggestionListScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + domain = "films.example", + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"), + CuratedField( + "watchUrl", + CuratedFieldType.Url, + isRequired = false, + label = "Watch URL", + config = CuratedFieldConfig(tag = "r", marker = "watch") + ), + CuratedField( + "review", + CuratedFieldType.LongText, + isRequired = false, + label = "Review", + config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) + ), + ), + relays = listOf("wss://relay.one.example") + ) + ) + + @Test + fun `the queue is named after the list, counted, and read from where the schema says`() = render( + state( + suggestions = listOf( + suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), + suggestion("Magic Money", 1_700_000_000, isCurated = false) + ) + ) + ) { + // The domain, as the card on the group's screen names it. + onNodeWithText("films.example").assertIsDisplayed() + onNodeWithText("2 suggestions · 1 curated").assertIsDisplayed() + onNodeWithText("Read from relay.one.example").assertIsDisplayed() + + // Newest first. + val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top + assertTrue(newest < oldest, "the queue was drawn oldest first") + } + + @Test + fun `a row shows what an entry is, not where to find it, and who suggested it`() = render( + state( + suggestions = listOf( + suggestion( + "Magic Money", + 1_700_000_000, + isCurated = false, + suggester = Profile( + publicKey = ada, + displayName = "Ada", + nostrEventId = "e".repeat(64), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ) + ) + ) + ) + ) { + // Labelled, because the screen does not know this list's fields any + // better than a stranger does; the link left to the sheet. + onNodeWithText("Year: 2018 · Type: documentary").assertIsDisplayed() + onNodeWithText("https://example.com/watch").assertDoesNotExist() + // The body, where the list has one. + onNodeWithText("Follows the early community.").assertIsDisplayed() + onNodeWithText("Suggested by Ada on", substring = true).assertIsDisplayed() + + onNodeWithText("1 suggestion").assertIsDisplayed() + onNodeWithText("Curated").assertDoesNotExist() + } + + @Test + fun `only the row the group has taken up is marked`() = render( + state( + suggestions = listOf( + suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), + suggestion("Magic Money", 1_700_000_000, isCurated = false) + ) + ) + ) { + onAllNodesWithText("Curated").assertCountEquals(1) + + val mark = onNodeWithText("Curated").getBoundsInRoot() + val curated = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot() + val waiting = onNodeWithText("Magic Money").getBoundsInRoot() + + assertTrue(mark.top < waiting.top, "the mark fell onto the row still waiting") + assertTrue(mark.bottom > curated.top, "the mark floated above the row it is about") + } + + @Test + fun `a queue still being asked for is looking, not empty`() = render( + state(suggestions = emptyList(), isAsking = true) + ) { + onNodeWithText("Asking the list's relays…").assertIsDisplayed() + onNodeWithText("Nobody has suggested anything to this list yet.").assertDoesNotExist() + onNodeWithText("Ask again").assertDoesNotExist() + } + + @Test + fun `an empty queue says so, and offers to ask again`() = render( + state(suggestions = emptyList(), isAsking = false) + ) { + onNodeWithText("Nobody has suggested anything to this list yet.").assertIsDisplayed() + onNodeWithText("Ask again").assertIsDisplayed() + onNodeWithText("Asking the list's relays…").assertDoesNotExist() + onNodeWithText("0 suggestions").assertIsDisplayed() + } + + @Test + fun `the sheet has every field the entry answered, the link included, and the event`() = + runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CuratedSuggestionSheetContent( + suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true), + schema = schema.schema + ) + } + } + } + + onNodeWithText("Magic Money").assertIsDisplayed() + onNodeWithText("Taken up by the group into the list.").assertIsDisplayed() + + // As the schema labels them, in the schema's order. + onNodeWithText("Year").assertIsDisplayed() + onNodeWithText("2018").assertIsDisplayed() + onNodeWithText("Watch URL").assertIsDisplayed() + onNodeWithText("https://example.com/watch").assertIsDisplayed() + onNodeWithText("Review").assertIsDisplayed() + onNodeWithText("Follows the early community.").assertIsDisplayed() + + // The event, whole, and a way to take it somewhere. + onNodeWithText("\"kind\":31888", substring = true).assertIsDisplayed() + onNodeWithText("Copy the suggestion").assertIsDisplayed() + } + + private fun suggestion( + title: String, + createdAt: Long, + isCurated: Boolean, + suggester: Profile? = null + ): CuratedSuggestion { + val id = createdAt.toString().padStart(64, 'e') + val identifier = title.lowercase().replace(' ', '-') + + return CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = id, + pubKey = suggester?.publicKey ?: bob, + createdAt = createdAt, + identifier = identifier, + title = title, + values = mapOf( + "identifier" to listOf(identifier), + "title" to listOf(title), + "year" to listOf("2018"), + "type" to listOf("documentary"), + "watchUrl" to listOf("https://example.com/watch"), + "review" to listOf("Follows the early community."), + ) + ), + event = NostrEvent( + id = id, + pubKey = suggester?.publicKey ?: bob, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), + content = "Follows the early community.", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + suggester = suggester, + isCurated = isCurated + ) + } + + private fun state( + suggestions: List, + isAsking: Boolean = false + ) = CuratedSuggestionListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + relays = listOf("wss://relay.one.example"), + suggestions = suggestions, + isAsking = isAsking + ) + + /** + * The screen at a phone's width and a window tall enough to compose the + * whole of it. The state is passed in, so the NO_OP repositories are never + * asked for anything: `initiate` only runs for a screen that arrives loading. + */ + private fun render( + uiState: CuratedSuggestionListUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + CuratedSuggestionListScreen( + activeUserPublicKey = ada, + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = uiState, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateBack = {} + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index dead059f..b89d56eb 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -250,8 +250,37 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Films worth translating").assertIsDisplayed() onNodeWithText("Add schema").assertDoesNotExist() onNodeWithContentDescription("Edit schema").assertDoesNotExist() + + // The queue is the one thing in the block the group did not write, + // and reading it takes no share of the key. + onNodeWithText("View suggestions").assertIsDisplayed() } + @Test + fun `every schema offers its own queue, under its own card`() = render( + state( + groupNostrProfile = profile, + schemas = listOf( + schema("films", "Films worth translating", 1_700_000_900), + schema("books", "Books worth translating", 1_700_000_000) + ) + ) + ) { + // One per list, because each list has its own queue -- and under the + // card it belongs to rather than once for the section, since a button + // between two cards would say nothing about which it opened. + val buttons = onAllNodesWithText("View suggestions") + buttons.assertCountEquals(2) + + val films = onNodeWithText("Films worth translating").getBoundsInRoot() + val books = onNodeWithText("Books worth translating").getBoundsInRoot() + val first = buttons[0].getBoundsInRoot() + val second = buttons[1].getBoundsInRoot() + + assertTrue(films.top < first.top && first.top < books.top, "the first queue button is not under its card") + assertTrue(books.top < second.top, "the second queue button is not under its card") + } + @Test fun `a member holding a share of the key may propose any event the block can show`() = render( state(groupNostrProfile = profile, relayLists = signedRelayLists()) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt new file mode 100644 index 00000000..73b3463f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt @@ -0,0 +1,335 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The decisions the queue screen's view model makes that nothing else can check. + * + * **Where the queue is read from.** The schema's relays, then the group's, then + * the default -- and getting the order wrong is quiet, because a queue read + * from the wrong relay is an empty queue with no error attached. + * + * **What is asked for.** One request per relay carrying the NIP's two queries, + * and the local watch over the same rows, so that what comes back is what is + * shown. + * + * **When looking stops.** Rows arriving end it; nothing arriving leaves it to + * the timer. A screen that stopped looking on an empty first read of the local + * table would call every list empty before its relays had been asked. + */ +class CuratedSuggestionListViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + + private fun schema(relays: List) = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), + relays = relays, + ).normalized(), + ) + + private val localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ) + + private fun general(relays: List, agreed: Boolean = true) = GroupRelayList( + set = GroupRelaySet.General, + signedEvent = if (agreed) { + GroupSignedEvent( + id = "a".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = GroupRelaySet.General.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + } else { + null + }, + relays = relays, + ) + + @Test + fun `the schema's own relays come first, and two spellings of one are one`() { + val asked = CuratedSuggestionListViewModel.relaysToAsk( + schema(listOf("wss://relay.one.example", "wss://relay.two.example/", "wss://relay.one.example/")), + general = general(listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example")))), + ) + + assertEquals(listOf(one.url, two.url), asked) + assertEquals(2, asked.size, "one relay spelled two ways was asked twice") + } + + @Test + fun `a schema naming no relays is read from where the group reads`() { + val three = RelayUrlNormalizer.normalize("wss://relay.three.example") + val four = RelayUrlNormalizer.normalize("wss://relay.four.example") + + // The group's read relays, and not its write-only one: this is a read. + assertEquals( + listOf(three.url), + CuratedSuggestionListViewModel.relaysToAsk( + schema(emptyList()), + general = general(listOf(GroupRelay(three), GroupRelay(four, read = false, write = true))), + ) + ) + + // Nothing agreed, or agreed empty: the build's own relay rather than nobody. + assertEquals( + listOf(Relays.ephemeral.url), + CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = null) + ) + assertEquals( + listOf(Relays.ephemeral.url), + CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = general(emptyList())) + ) + assertEquals( + listOf(Relays.ephemeral.url), + CuratedSuggestionListViewModel.relaysToAsk( + schema(emptyList()), + general = general(listOf(GroupRelay(three)), agreed = false) + ) + ) + } + + @Test + fun `one request per relay, carrying the NIP's two queries for the coordinate`() { + val schema = schema(listOf(one.url)) + val requests = CuratedSuggestionListViewModel.requestsFor(schema, listOf(one.url, two.url)) + + assertEquals(listOf(one.url, two.url), requests.map { it.relayURL }) + assertTrue(requests.all { it.purpose == CuratedSuggestionListViewModel.PURPOSE }) + + val (suggestions, canonical) = requests.first().synchronizationFilters + val coordinate = "31889:$chatRoomId:films" + + assertEquals(listOf(31888), suggestions.kinds?.toList()) + assertEquals(mapOf("a" to listOf(coordinate)), suggestions.tags) + assertEquals(null, suggestions.authors, "suggestions are not scoped by author: anyone may make one") + + assertEquals(listOf(31890), canonical.kinds?.toList()) + assertEquals(listOf(chatRoomId), canonical.authors?.toList(), "canonical entries are the group's alone") + assertEquals(mapOf("a" to listOf(coordinate)), canonical.tags) + + // And the watch covers both, so what is asked for is what is shown. + val watched = schema.queueReadFilter() + assertEquals(listOf(31888, 31890), watched.kinds?.toList()) + assertEquals(mapOf("a" to listOf(coordinate)), watched.tags) + } + + @Test + fun `rows arriving end the looking, and an empty read does not`() { + val asking = CuratedSuggestionListUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema(listOf(one.url)), + relays = listOf(one.url), + isAsking = true, + ) + + val stillEmpty = CuratedSuggestionListViewModel.withQueue(asking, emptyList()) + assertTrue(stillEmpty.isAsking, "an empty first read of the local table was taken as the relays' answer") + + val arrived = CuratedSuggestionListViewModel.withQueue(asking, listOf(row("Magic Money", 1_700_000_000))) + assertFalse(arrived.isAsking) + assertEquals(listOf("Magic Money"), arrived.suggestions.map { it.entry.title }) + + // Once the timer has called it empty, a later arrival is still shown. + val late = CuratedSuggestionListViewModel.withQueue(stillEmpty.copy(isAsking = false), listOf(row("Late", 1_700_000_900))) + assertFalse(late.isAsking) + assertEquals(listOf("Late"), late.suggestions.map { it.entry.title }) + } + + @Test + fun `opening the screen asks the schema's relays and shows what the table holds for them`() { + val schema = schema(listOf(one.url)) + val queued = mutableListOf() + val watched = mutableListOf() + + val stored = NostrEvent( + id = "1".repeat(64), + pubKey = ada, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("a", schema.coordinate(), "", "root"), + ), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { + queued += synchronizeNostrEventRequests + } + + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { + watched += synchronizationFilter + return flowOf(listOf(LocalNostrEvent(nostrEvent = stored, profile = null))) + } + } + + val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom + override suspend fun groupCuratedSchemas(chatRoomId: String): List = listOf(schema) + override suspend fun groupRelayLists(chatRoomId: String): List = emptyList() + } + + val viewModel = CuratedSuggestionListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = ada, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ) + + try { + viewModel.initiate() + + // The read runs on IO and lands on the state when it lands; the + // assertion waits for the rows rather than for a fixed time. + val loaded = runBlocking { + withTimeout(5_000) { + while (true) { + val state = viewModel.curatedSuggestionListUIState + if (state is CuratedSuggestionListUIState.Loaded && state.suggestions.isNotEmpty()) break + delay(20) + } + assertIs(viewModel.curatedSuggestionListUIState) + } + } + + assertEquals(listOf(one.url), loaded.relays) + assertEquals(listOf(one.url), queued.map { it.relayURL }) + assertEquals(listOf(schema.queueReadFilter()), watched) + assertEquals(listOf("Magic Money"), loaded.suggestions.map { it.entry.title }) + assertFalse(loaded.isAsking, "rows arrived and the screen is still looking") + } finally { + // The timer would otherwise run on for ten seconds after the test. + viewModel.viewModelScope.cancel() + } + } + + @Test + fun `a schema this device does not hold is an error rather than an empty queue`() { + val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom + override suspend fun groupCuratedSchemas(chatRoomId: String): List = emptyList() + } + + val viewModel = CuratedSuggestionListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = ada, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = chatRepository, + ) + + try { + viewModel.initiate() + + runBlocking { + withTimeout(5_000) { + while (viewModel.curatedSuggestionListUIState == CuratedSuggestionListUIState.Loading) delay(20) + } + } + + assertEquals( + CuratedSuggestionListUIState.Error("Couldn't find that schema"), + viewModel.curatedSuggestionListUIState + ) + } finally { + viewModel.viewModelScope.cancel() + } + } + + private fun row(title: String, createdAt: Long) = CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = createdAt.toString().padStart(64, 'e'), + pubKey = ada, + createdAt = createdAt, + identifier = title.lowercase().replace(' ', '-'), + title = title, + values = mapOf("title" to listOf(title)), + ), + event = NostrEvent( + id = createdAt.toString().padStart(64, 'e'), + pubKey = ada, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = emptyArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt), + ), + suggester = null, + isCurated = false, + ) +} diff --git a/docs/scripts/m3-title-case.py b/docs/scripts/m3-title-case.py index 02a4c11a..51f154c5 100755 --- a/docs/scripts/m3-title-case.py +++ b/docs/scripts/m3-title-case.py @@ -29,6 +29,8 @@ SMALL = {'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'via', SAMPLE = { 'Steve Biko', 'John Doe', 'Frank Talk', 'Alan Turing', 'To Kill a Mockingbird', 'Man With A Plan', 'Woman Of Few Words', + # Films, as suggested to a curated list in the queue screen's preview. + 'The Rise and Rise of Bitcoin', 'Magic Money', } From 39b1c714b82275e2fa338b747311e0076816d015 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 13:55:29 +0200 Subject: [PATCH 22/71] feat(groups): a broadcast button under each signed event, and the relays it goes to Until now nothing a group signed reached a relay. `FrostSigningManager.complete` files the batch as `GroupSignedEvent` rows and applies it locally, and the profile, the relay lists, the posts and the schemas are all read off those rows -- the docs on each said so, and the only way out was the copy button on the key-state sheet. This is the way out: a "Broadcast" button under every signed event in the identity block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits the relays it will go to, presses once, and reads what each relay answered. The raw JSON is at the bottom for anyone who would rather use another tool. **The send goes to the relay pool directly, not through the broadcast queue.** Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, which is durable and retried, and it is not used here on purpose. The queue hangs off a `NostrEvent` row, and a row for the group's event is more than a queue entry: the home feed selects by kind, so the group's post would appear in it as anybody's; and the sync loop offers every local event id to the relays it reconciles with, so the event would reach relays the screen never named -- which would make "edit the relays" a fiction. `GroupSignedEvent` already explains at length why it is not a `NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of `RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is and for the same reason: the manager cannot be stood up in a test. The cost is that a broadcast is only as durable as the button press. Each row says what its relay said, and a relay that did not answer is sent to again by pressing again. **Behind no gate.** Every other button in the block is hidden from a member holding no share of the key, because each proposes a signature by the group. Sending what the group has already signed takes no share: the signature is the group's whoever repeats it, and any member could paste the JSON into another client today. So the button is there for whoever is looking, the way the suggestion queue is. **Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's General list where it has agreed one -- its write relays, and nothing from the app's set beside them, since a member who edited the relays meant them -- and the app's publish set where it has not. A relay list also goes to the indexers, because a list sent only to the relays it names is circular; a curated schema also goes to the relays it names for its own replies, so a client reading suggestions there finds what they answer. Nothing goes to a relay the group has blocked. The seed happens once, so a list the member emptied stays empty. **Each relay's answer is shown in the relay's words.** The pool answers once per relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and any other error -- and the rows keep them apart, because "blocked: not on the allow list" is something a member can act on and a red icon is not. A relay still unanswered when the whole send runs out of time reads "No answer", which is not the same as refused. Answers are matched by host, since the pool names a relay by the URL it first opened a socket with. The relay-list rows get their button inside the shared card, under the row and at the end, because the four rows share one card and "under the event" has to mean under the row. An agreed empty list gets one too: a withdrawal is a statement, and relays still holding the old list need to hear it. The key-state event and the subgroup certificates get none -- `ChronicleManager` keeps the key state off even the members-only path, and a public relay is a bigger audience than that. `BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only seeding, the three answer shapes and the host matching, and that what goes on the wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest` presses the button against a fake pool and finds each answer under its relay with the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group with a profile, two agreed lists, a post and a schema, each under its own card, and finds them still there for a member with no share. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@6ae5a9676eb01fcda49024caffff1a8bdc335831 --- .../composeResources/values/strings.xml | 14 + .../database/model/GroupSignedEvent.kt | 13 +- .../repository/DatabaseChatRepository.kt | 7 + .../compose/managers/FrostSigningManager.kt | 8 +- .../network/relays/EventPublishTransport.kt | 32 + .../network/relays/RelaysSocketManager.kt | 4 +- .../mantra/compose/nostr/GroupNostrProfile.kt | 12 +- .../mantra/compose/nostr/GroupRelayList.kt | 7 +- .../compose/repository/ChatRepository.kt | 16 + .../BroadcastGroupSignedEventScreen.kt | 627 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 168 ++++- .../ui/composable/navigation/MantraNavHost.kt | 21 + .../routes/BroadcastGroupSignedEventRoute.kt | 12 + .../BroadcastGroupSignedEventViewModel.kt | 441 ++++++++++++ .../state/BroadcastGroupSignedEventUIState.kt | 26 + .../BroadcastGroupSignedEventScreenJvmTest.kt | 240 +++++++ .../GroupNostrProfileSectionJvmTest.kt | 52 ++ ...oadcastGroupSignedEventViewModelJvmTest.kt | 370 +++++++++++ 18 files changed, 2020 insertions(+), 50 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f9412875..c85da50e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -585,6 +585,20 @@ Taken up by the group into the list. Copy the suggestion Copied the suggestion + + Broadcast + Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours. + Where the group has said it lives is filled in. Add or remove relays before sending. + No relays to broadcast to. Add one above. + Add a relay to broadcast to. + Sending… + Accepted + Rejected: %1$s + Couldn't send: %1$s + No answer + Accepted by %1$s of %2$s relays. + Copy raw JSON + Copied the raw JSON A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec. An npub is a public key. Mantra needs the secret key, the nsec, to sign as you. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt index eaa2fffb..e2876be2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt @@ -28,14 +28,19 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent * * ### Why the whole event, and not a pointer to one * - * There is nothing to point at. A group-signed event never travels on the wire - * as itself -- the outbound pipeline re-authors rumors as their sender and would - * strip the signature off -- so it is not a `NostrEvent`, and it is not a - * `MarmotInnerEvent` either, since no member sent it. Every device derives it + * There is nothing to point at. A group-signed event never travels between + * members as itself -- the outbound pipeline re-authors rumors as their sender + * and would strip the signature off -- so it is not a `NostrEvent`, and it is not + * a `MarmotInnerEvent` either, since no member sent it. Every device derives it * from a signing session it took part in, or is handed it in a chronicle. The * columns are `NostrEvent`'s so that what is stored is an event rather than a * summary of one, which is what makes [verifies] answerable from the row alone. * + * The one way one reaches a relay is a member sending it there by hand, from + * `BroadcastGroupSignedEventScreen`, and that path serialises the row straight + * to the pool without ever making a `NostrEvent` of it -- see + * `BroadcastGroupSignedEventViewModel` for why a row would be the wrong shape. + * * ### The derivation path * * [publicKey] is the group's threshold key walked to [derivationPath], and for a diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 01acecfd..40f4f4ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -183,6 +183,13 @@ class DatabaseChatRepository( emptyList() } + override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { + database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId } + } catch (e: Throwable) { + logger.e("Error reading the signed event $id of $chatRoomId", e) + null + } + override suspend fun canSign(chatRoomId: String): Boolean = try { FrostSigningManager.canSign(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index bf049bee..5dfa9ce4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -925,9 +925,11 @@ object FrostSigningManager { // signature by now, so each applies the result itself rather than // waiting to be sent something it can already build -- the same // reasoning the transcript lines are written on. Nothing goes on the - // wire: a signed event authored by the threshold key cannot travel as - // an inner event anyway, because the outbound pipeline re-authors - // rumors as their sender and would strip the group's signature off. + // wire from here: a signed event authored by the threshold key cannot + // travel as an inner event anyway, because the outbound pipeline + // re-authors rumors as their sender and would strip the group's + // signature off. Sending one to relays is a member's separate act, + // from `BroadcastGroupSignedEventScreen`, off the recorded row. signedEvents.forEach { applySignedEvent(database, session, it, recorded) } announce( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt new file mode 100644 index 00000000..a15fd5db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.network.relays + +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.network.dto.RelayDTO + +/** + * The slice of the relay layer a one-off publish needs. + * + * The same cut [LiveSubscriptionTransport] makes and for the same reason: + * [RelaysSocketManager] starts collecting relay lists in its `init` and cannot be + * stood up in a test, so a screen that hands it an event would otherwise have no + * way to check what it does with the answers. + * + * Everything the app publishes as *itself* goes through the + * `BroadcastNostrEventRequest` queue instead, which is durable and retried. This + * exists for the one thing that must not: an event the group signed. The queue + * hangs off a `NostrEvent` row, and a row for the group's event would put its post + * into the home feed and offer it to every relay the sync loop reconciles with -- + * see `BroadcastGroupSignedEventViewModel` for the whole of that argument. + */ +interface EventPublishTransport { + + /** + * Sends [nostrEvent] to each of [relays], opening a socket to any it does not + * already hold, and answers once per relay: the relay's OK, its rejection, or + * the error that stopped it being asked. + * + * @see RelayPool.publishEvent + */ + suspend fun publishEvent(nostrEvent: NostrEvent, relays: Set): Flow +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index 392b0b7d..7d796b02 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -32,7 +32,7 @@ class RelaysSocketManager( private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository, -) : LiveSubscriptionTransport { +) : LiveSubscriptionTransport, EventPublishTransport { val logger = Logger.withTag("RelaysSocketManager") private val scope = CoroutineScope(Dispatchers.IO) private val relayPoolsMutex = Mutex() @@ -116,7 +116,7 @@ class RelaysSocketManager( } @Throws(press.mantra.compose.exceptions.NostrPublishException::class) - suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow { + override suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow { logger.d("publishEvent: $nostrEvent") return relayPool.publishEvent( nostrEvent = nostrEvent, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt index 536b21f1..f66c75d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt @@ -25,11 +25,13 @@ import kotlin.time.Instant * events in that position. So the group's profile lives where the group's other * signed statements live, and this is the reading of it. * - * The consequence worth stating: **nothing here has reached a relay.** A member's - * kind:0 is published and is how the rest of nostr learns their name; a group's is - * signed and kept, and every device that followed the signing session -- or was - * handed the event afterwards -- holds it. Publishing it would mean a `NostrEvent` - * row for an event no member authored, which is a decision this does not make. + * The consequence worth stating: **nothing here reaches a relay by being signed.** + * A member's kind:0 is published and is how the rest of nostr learns their name; a + * group's is signed and kept, and every device that followed the signing session + * -- or was handed the event afterwards -- holds it. It reaches a relay when a + * member sends it there from `BroadcastGroupSignedEventScreen`, which serialises + * the signed row directly rather than making a `NostrEvent` row for an event no + * member authored -- a decision this still does not make. * * ### What a reading has to earn * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt index 82e31bef..9ddbbfe3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt @@ -30,11 +30,12 @@ import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag as Nip17RelayTag * reasons `GroupNostrProfile` gives at length: a group-signed event is not a * `NostrEvent`, and only an event the room itself authored is the group speaking. * - * ### Nothing here has reached a relay either + * ### Nothing here reaches a relay by being signed * * The lists say where the group's work *should* go; sending it there is a separate - * job this does not do. See `GroupNostrProfile`, which carries the same caveat for - * the same reason. + * act, taken by a member one event at a time from `BroadcastGroupSignedEventScreen`, + * and the General list is what that screen fills its relay picker from. See + * `GroupNostrProfile`, which carries the same caveat for the same reason. * * ### Public tags only, and not by preference * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 7ac07fa4..686f8091 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -125,6 +125,17 @@ interface ChatRepository { */ suspend fun groupCuratedSchemas(chatRoomId: String): List + /** + * One event the group signed, as the group signed it, or null when this + * device does not hold it or it was signed in some other room. + * + * Scoped to the room rather than looked up by id alone, because the id + * arrives as a navigation argument and the screen it opens names a room in + * its title: an event from one room shown under another room's name would + * be a lie the signature check cannot catch, since both rooms' events verify. + */ + suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? + /** * Whether this device holds a share of the group's key, and so could take * part in signing for it. @@ -317,6 +328,11 @@ interface ChatRepository { chatRoomId: String ): List = emptyList() + override suspend fun groupSignedEvent( + chatRoomId: String, + id: HexKey + ): GroupSignedEvent? = null + override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun refuseSubgroup( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt new file mode 100644 index 00000000..7314fbfa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -0,0 +1,627 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CellTower +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.accepted_by_n_of_m_relays +import mantra.composeapp.generated.resources.add_a_relay_to_broadcast_to +import mantra.composeapp.generated.resources.add_relay +import mantra.composeapp.generated.resources.broadcast +import mantra.composeapp.generated.resources.copied_the_raw_json +import mantra.composeapp.generated.resources.copy_raw_json +import mantra.composeapp.generated.resources.eg_wss_relay_example_com +import mantra.composeapp.generated.resources.no_relays_to_broadcast_to +import mantra.composeapp.generated.resources.relay_accepted +import mantra.composeapp.generated.resources.relay_could_not_send +import mantra.composeapp.generated.resources.relay_no_answer +import mantra.composeapp.generated.resources.relay_rejected +import mantra.composeapp.generated.resources.relay_sending +import mantra.composeapp.generated.resources.relay_url +import mantra.composeapp.generated.resources.relays +import mantra.composeapp.generated.resources.sends_the_event_exactly_as_the_group_signed_it +import mantra.composeapp.generated.resources.signed_by_the_group_on +import mantra.composeapp.generated.resources.that_is_not_a_relay_address +import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list +import mantra.composeapp.generated.resources.the_signed_event +import mantra.composeapp.generated.resources.where_the_group_has_said_it_lives_is_filled_in +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel +import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlin.time.Instant + +/** + * One event the group signed, and the relays to send it to. + * + * The group's screen shows what the group has signed; this is where one of those + * leaves the device. The list starts at where the group has said it lives and the + * member can change it, the button sends to every relay in it at once, and each + * row then says what its relay answered -- accepted, rejected in the relay's own + * words, or nothing. The raw JSON is at the bottom for anyone who would rather + * hand it to some other tool. + * + * **The button sends rather than proposes.** Every other screen off the identity + * block opens a signing session, because what it makes is a statement by the + * group. This one moves a statement the group has already made, which any member + * holding it may do: the signature is the group's, and no share of the key is + * needed to repeat it. See `BroadcastGroupSignedEventViewModel` for why the send + * goes to the pool directly rather than through the app's broadcast queue. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun BroadcastGroupSignedEventScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + signedEventId: HexKey, + initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState = + BroadcastGroupSignedEventUIState.Loading, + chatRepository: ChatRepository, + publishTransport: EventPublishTransport, + onNavigateBack: () -> Unit, +) { + val broadcastGroupSignedEventViewModel: BroadcastGroupSignedEventViewModel = viewModel( + factory = BroadcastGroupSignedEventViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = signedEventId, + initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState, + chatRepository = chatRepository, + publishTransport = publishTransport, + ) + ) + + val clipboardManager = LocalClipboardManager.current + + // Read out here rather than in a click handler: both are composable and an + // onClick lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val noRelays = stringResource(Res.string.add_a_relay_to_broadcast_to) + val copied = stringResource(Res.string.copied_the_raw_json) + + ScreenStateTransition(broadcastGroupSignedEventViewModel.broadcastGroupSignedEventUIState) { uiState -> + when (val broadcastGroupSignedEventUIState = uiState) { + is BroadcastGroupSignedEventUIState.Error -> { + // Nothing to retry: the room and the event came from navigation + // arguments, and reading them again with the same ones fails the + // same way. + ErrorState(message = broadcastGroupSignedEventUIState.message, onRetry = null) + } + + is BroadcastGroupSignedEventUIState.Loaded -> { + val signedEvent = broadcastGroupSignedEventUIState.signedEvent + val urlFieldState = rememberTextFieldState() + val isPending = broadcastGroupSignedEventViewModel.isActionPending.value + val relays = broadcastGroupSignedEventViewModel.relays + + // The list comes from the state rather than from whatever loaded + // it, so a screen handed a loaded state -- a preview, a layout + // test -- fills in the same as the app does. Once only, so a list + // the member emptied stays empty. + LaunchedEffect(broadcastGroupSignedEventUIState) { + broadcastGroupSignedEventViewModel.seedRelays( + relayLists = broadcastGroupSignedEventUIState.relayLists, + kind = signedEvent.kind, + tags = signedEvent.tags, + content = signedEvent.content, + ) + } + + // A refusal is about what is in the field, so it goes the moment + // the field changes rather than sitting under a URL that has since + // been corrected. + LaunchedEffect(urlFieldState) { + snapshotFlow { urlFieldState.text.toString() } + .collect { broadcastGroupSignedEventViewModel.clearAddFailure() } + } + + // As the group signed it, byte for byte: what is copied is exactly + // what goes on the wire, because the point of copying it is to + // hand somebody something they can verify. + val json = signedEvent.toEvent().toJson() + val summary = ProposedEvent.summarize(signedEvent.toEvent()) + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + val canSend = !isPending + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + broadcastGroupSignedEventUIState.localChatRoom.RenderChatRoomTitleText() + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSend) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSend) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSend) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSend) return@ExtendedFloatingActionButton + + broadcastGroupSignedEventViewModel.broadcast( + signedEvent = signedEvent, + // Stays on the screen: an empty list is + // a question rather than a mistake. + onNoRelays = { notify(noRelays) } + ) + } + ) { + Icon( + Icons.Default.CellTower, + contentDescription = "Broadcast to these relays" + ) + Text(stringResource(Res.string.broadcast)) + } + } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + // What is being sent, in the words the signing screen used + // for it: a member deciding where a post goes is deciding + // about a post, and "kind 1" answers a question nobody asked. + item { + Column( + modifier = Modifier.fillMaxWidth() + .padding(top = MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = summary.label, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = summary.detail, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4 + ) + + Text( + text = stringResource( + Res.string.signed_by_the_group_on, + signedEvent.createdAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + item { + Text( + text = stringResource( + Res.string.sends_the_event_exactly_as_the_group_signed_it + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + Text( + text = stringResource(Res.string.relays), + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = stringResource( + Res.string.where_the_group_has_said_it_lives_is_filled_in + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = urlFieldState, + lineLimits = TextFieldLineLimits.SingleLine, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(Icons.Default.Dns, contentDescription = Decorative) + }, + label = { Text(stringResource(Res.string.relay_url)) }, + placeholder = { + Text(stringResource(Res.string.eg_wss_relay_example_com)) + }, + isError = broadcastGroupSignedEventViewModel.addFailure != null + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + IconButton( + onClick = { + if ( + broadcastGroupSignedEventViewModel.addRelay( + urlFieldState.text.toString() + ) + ) { + urlFieldState.clearText() + } + } + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.add_relay) + ) + } + } + + // Named rather than silent. An add button that does + // nothing for a URL it refuses is indistinguishable from + // a missed tap. + broadcastGroupSignedEventViewModel.addFailure?.let { failure -> + Text( + text = when (failure) { + BroadcastGroupSignedEventViewModel.AddFailure.NotARelay -> + stringResource(Res.string.that_is_not_a_relay_address) + BroadcastGroupSignedEventViewModel.AddFailure.AlreadyListed -> + stringResource( + Res.string.that_relay_is_already_in_this_list + ) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + + // The last press, summed up, above the rows that break it + // down. Persistent rather than a snackbar, because it is + // the answer the member came for and a snackbar would be + // gone before the last relay had been read. + broadcastGroupSignedEventViewModel.lastBroadcast?.let { last -> + item { + Text( + text = stringResource( + Res.string.accepted_by_n_of_m_relays, + last.accepted, + last.asked + ), + style = MaterialTheme.typography.bodyMedium, + color = if (last.accepted == last.asked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } + ) + } + } + + if (relays.isEmpty()) { + item { + EmptyState( + message = stringResource(Res.string.no_relays_to_broadcast_to), + icon = Icons.Default.Dns + ) + } + } else { + items(items = relays, key = { it.url }) { relay -> + BroadcastRelayRow( + relay = relay, + outcome = broadcastGroupSignedEventViewModel.outcomes[relay], + // A row cannot leave while its relay is being + // asked: the answer would arrive for a relay + // that is no longer there to show it. + canRemove = !isPending, + onRemove = { + broadcastGroupSignedEventViewModel.removeRelay(relay) + } + ) + } + } + + item { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + Text( + text = stringResource(Res.string.the_signed_event), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = json, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding) + ) + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + FilledTonalButton( + onClick = { + clipboardManager.setText(AnnotatedString(json)) + notify(copied) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.copy_raw_json)) + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + } + } + } + } + + BroadcastGroupSignedEventUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.broadcast), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialBroadcastGroupSignedEventUIState == BroadcastGroupSignedEventUIState.Loading) { + broadcastGroupSignedEventViewModel.initiateBroadcastGroupSignedEvent() + } + } +} + +/** + * One relay to send to, and what it said the last time it was asked. + * + * The answer is on a second line under the host rather than in an icon, because + * the answers that matter are the relay's own words -- "blocked: not on the + * allow list" is something a member can act on, and a red icon is not. + */ +@Composable +private fun BroadcastRelayRow( + relay: NormalizedRelayUrl, + outcome: RelayOutcome?, + canRemove: Boolean, + onRemove: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + // The host rather than the whole URL: every relay here starts + // `wss://` -- nothing else is allowed in -- so the scheme is four + // characters of nothing in front of the part that differs. + text = relay.displayUrl(), + style = MaterialTheme.typography.bodyMedium + ) + + outcome?.let { + Text( + text = when (it) { + RelayOutcome.Sending -> stringResource(Res.string.relay_sending) + RelayOutcome.Accepted -> stringResource(Res.string.relay_accepted) + is RelayOutcome.Rejected -> + stringResource(Res.string.relay_rejected, it.reason) + is RelayOutcome.Failed -> + stringResource(Res.string.relay_could_not_send, it.reason) + RelayOutcome.NoAnswer -> stringResource(Res.string.relay_no_answer) + }, + style = MaterialTheme.typography.bodySmall, + color = when (it) { + RelayOutcome.Accepted -> MaterialTheme.colorScheme.primary + is RelayOutcome.Rejected, is RelayOutcome.Failed -> + MaterialTheme.colorScheme.error + RelayOutcome.Sending, RelayOutcome.NoAnswer -> + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + + IconButton(onClick = onRemove, enabled = canRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove relay", + // The button's own disabled colour while a send is out, rather + // than a red that promises a tap will do something. + tint = if (canRemove) MaterialTheme.colorScheme.error else LocalContentColor.current + ) + } + } +} + +@ConformancePreviews +@Composable +private fun BroadcastGroupSignedEventScreenPreview() { + val chatRoomId = "d4c3b2a1".repeat(8) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + BroadcastGroupSignedEventScreen( + activeUserPublicKey = "", + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = "b".repeat(64), + initialBroadcastGroupSignedEventUIState = BroadcastGroupSignedEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + signedEvent = GroupSignedEvent( + id = "b".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "The first chapter is out.", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + // Nothing agreed, so the list fills in from the app's own + // publish set -- the state a group meets this screen in + // before it has edited its relays. + relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId), + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + publishTransport = object : EventPublishTransport { + override suspend fun publishEvent( + nostrEvent: press.mantra.compose.database.model.NostrEvent, + relays: Set + ): Flow = emptyFlow() + }, + onNavigateBack = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 0f2483a7..65e4ea70 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -1,6 +1,9 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -25,6 +28,7 @@ import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.CellTower import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw @@ -79,6 +83,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute @@ -170,6 +175,7 @@ import mantra.composeapp.generated.resources.this_room_signs_as import mantra.composeapp.generated.resources.derivation_path import mantra.composeapp.generated.resources.key_ceremony import mantra.composeapp.generated.resources.agreed_on +import mantra.composeapp.generated.resources.broadcast import mantra.composeapp.generated.resources.the_signed_event import mantra.composeapp.generated.resources.copy_the_signed_event import mantra.composeapp.generated.resources.copied_the_signed_event @@ -184,7 +190,7 @@ import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews import kotlin.time.Instant -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun ChatRoomDetailScreen( activeUserPublicKey: HexKey, @@ -240,6 +246,19 @@ fun ChatRoomDetailScreen( ) { var isShowingGroupKeyState by rememberSaveable { mutableStateOf(false) } + // The one way into the broadcast screen, for every signed + // event in the identity block below. + val broadcast: (GroupSignedEvent) -> Unit = { signedEvent -> + onNavigateToRoute.invoke( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = signedEvent.id + ) + ) + } + LazyColumn( modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally, @@ -381,10 +400,29 @@ fun ChatRoomDetailScreen( item { chatRoomDetailUIState.groupNostrProfile?.let { groupNostrProfile -> - GroupNostrProfileCard( - groupNostrProfile = groupNostrProfile, - publicKey = chatRoomId - ) + // The card and its broadcast button as one + // item, so the button stays under the event + // it sends however the list is arranged. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupNostrProfileCard( + groupNostrProfile = groupNostrProfile, + publicKey = chatRoomId + ) + + // Behind no gate, here and under every + // other signed event in the block: sending + // what the group has already signed takes + // no share of the key, since the + // signature on it is the group's whoever + // repeats it. Only *making* a statement + // is gated. + BroadcastButton( + onClick = { broadcast(groupNostrProfile.signedEvent) } + ) + } } ?: Text( stringResource( Res.string.this_group_has_not_said_anything_about_itself @@ -439,7 +477,8 @@ fun ChatRoomDetailScreen( item { GroupRelayListsCard( - relayLists = chatRoomDetailUIState.groupRelayLists + relayLists = chatRoomDetailUIState.groupRelayLists, + onBroadcast = broadcast ) } @@ -498,12 +537,21 @@ fun ChatRoomDetailScreen( items = chatRoomDetailUIState.groupPosts, key = { post -> post.signedEvent.id } ) { post -> - GroupPostCard( - post = post, - publicKey = chatRoomId, - groupNostrProfile = - chatRoomDetailUIState.groupNostrProfile - ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupPostCard( + post = post, + publicKey = chatRoomId, + groupNostrProfile = + chatRoomDetailUIState.groupNostrProfile + ) + + BroadcastButton( + onClick = { broadcast(post.signedEvent) } + ) + } } } @@ -602,30 +650,48 @@ fun ChatRoomDetailScreen( // strangers, and reading the answers // takes no share of the key. Per card // because each list has its own queue. - TextButton( - onClick = { - onNavigateToRoute.invoke( - CuratedSuggestionListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = curated.identifier + // + // Beside the broadcast button rather + // than over it, wrapping where the two + // will not fit on one line: both are + // about this one card and a column of + // buttons under every schema would + // read as a menu. + FlowRow( + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap, + Alignment.CenterHorizontally + ) + ) { + TextButton( + onClick = { + onNavigateToRoute.invoke( + CuratedSuggestionListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = curated.identifier + ) + ) + } + ) { + Icon( + Icons.Default.Inbox, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 ) ) + + Text(stringResource(Res.string.view_suggestions)) } - ) { - Icon( - Icons.Default.Inbox, - contentDescription = Decorative - ) - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.space125 - ) + BroadcastButton( + onClick = { broadcast(curated.signedEvent) } ) - - Text(stringResource(Res.string.view_suggestions)) } } } @@ -1461,7 +1527,8 @@ internal fun GroupKeyStateSheetContent( * The signing date is here because a group's profile is standing state with no * other clue to its age -- and because the alternative reading, that this arrived * from a relay a moment ago, is exactly what has not happened. Nothing on this card - * has been published: see `GroupNostrProfile`. + * was published by being signed -- see `GroupNostrProfile` -- and the broadcast + * button under it is how it gets to a relay. */ @Composable private fun GroupNostrProfileCard( @@ -1537,7 +1604,10 @@ private fun GroupNostrProfileCard( * deliberate withdrawal behind an oversight. */ @Composable -private fun GroupRelayListsCard(relayLists: List) { +private fun GroupRelayListsCard( + relayLists: List, + onBroadcast: (GroupSignedEvent) -> Unit +) { Card(modifier = Modifier.fillMaxWidth()) { relayLists.forEach { list -> ListItem( @@ -1563,10 +1633,42 @@ private fun GroupRelayListsCard(relayLists: List) { ) } ) + + // Under the row it sends, and only where there is something signed + // to send: a list the group has never agreed is not an event. Inside + // the card because the four rows share one, so "under the event" has + // to mean under the row -- and at the end, where M3 puts a card's + // actions. An agreed empty list gets one too: a withdrawal is a + // statement, and relays still holding the old list need to hear it. + list.signedEvent?.let { signedEvent -> + Box( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.compactPadding), + contentAlignment = Alignment.CenterEnd + ) { + BroadcastButton(onClick = { onBroadcast(signedEvent) }) + } + } } } } +/** + * The way into `BroadcastGroupSignedEventScreen`, drawn the same under every + * signed event in the identity block so that it reads as one action wherever it + * sits. The icon is decorative because the label is beside it. + */ +@Composable +private fun BroadcastButton(onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(Icons.Default.CellTower, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.broadcast)) + } +} + /** The name of a relay list, for the summary on the group's screen. */ private fun GroupRelaySet.summaryLabel() = when (this) { GroupRelaySet.General -> Res.string.relay_set_general diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 560951b9..62dc90ad 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen import press.mantra.compose.ui.composable.AddGroupPostScreen import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen +import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen import press.mantra.compose.ui.composable.CuratedSuggestionListScreen import press.mantra.compose.ui.composable.ProposeGroupEventScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen @@ -133,6 +134,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute @@ -1129,6 +1131,25 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + BroadcastGroupSignedEventScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + signedEventId = route.signedEventId, + chatRepository = databaseChatRepository, + // The one screen handed the pool itself rather than a queue + // to write to: a group-signed event must not become a + // `NostrEvent` row, and the queue hangs off one. See + // `BroadcastGroupSignedEventViewModel`. + publishTransport = synchronizationViewModel.relaysSocketManager, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt new file mode 100644 index 00000000..6e4cbaa1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt @@ -0,0 +1,12 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class BroadcastGroupSignedEventRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String?, + /** The `GroupSignedEvent` to send, which is also the event id the group signed. */ + val signedEventId: String, +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt new file mode 100644 index 00000000..383a0ceb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt @@ -0,0 +1,441 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.exceptions.NostrPublishException +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.network.relays.NostrPublishResult +import press.mantra.compose.network.relays.RelayPool +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import kotlin.time.Duration.Companion.milliseconds + +/** + * One event the group signed, sent to the relays a member picks. + * + * Signing and sending are two acts here, on purpose. `FrostSigningManager.complete` + * files what a quorum signed and applies it locally, and nothing leaves the device; + * this is the second act, taken by a member, one event at a time, to relays they + * can see and change before pressing the button. What goes on the wire is the + * event exactly as the group signed it -- the signature is the group's, so any + * member holding the event can send it and no share of the key is needed. + * + * ### Why not the broadcast queue + * + * Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, + * which is durable, retried and drained in the background. It is not used here + * because it hangs off a `NostrEvent` row, and a row for the group's event is + * more than a queue entry: the home feed selects by kind, so the group's post + * would appear in it as if it were anybody's; and the sync loop offers every + * local event id to the relays it reconciles with, so the event would reach + * relays this screen never named. `GroupSignedEvent` explains at length why it is + * not a `NostrEvent`; this keeps that true. The cost is that a broadcast is only + * as durable as the button press -- what each relay said is shown, and a relay + * that did not answer is sent to again by pressing again. + * + * ### Where the relays come from + * + * [defaultRelaysFor]: the group's own General list where it has agreed one, the + * app's publish set where it has not, plus the indexers for a relay list and the + * list's own relays for a schema, minus anything the group has blocked. The + * member can add and remove before sending, and the seeded list is a suggestion + * rather than a rule. + */ +class BroadcastGroupSignedEventViewModel( + val chatRoomId: String, + val signedEventId: HexKey, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState, + val chatRepository: ChatRepository, + val publishTransport: EventPublishTransport, +): ViewModel() { + + var broadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState by mutableStateOf( + initialBroadcastGroupSignedEventUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * The relays the event will be sent to, in the order they are shown. + * + * Held on the view model rather than in the composition, like the relay + * editor's working copy, so a rotation does not empty the list. + */ + val relays = mutableStateListOf() + + /** + * What each relay said the last time the button was pressed. + * + * Keyed by relay rather than kept as a list, because a relay removed after it + * answered takes its answer with it, and one added afterwards has none until + * the next press. + */ + val outcomes = mutableStateMapOf() + + /** + * Why the last add did not happen, or null. Cleared by the next keystroke. + * + * The same two refusals as the relay editor's, for the same reason: an add + * button that does nothing for a URL it refuses is indistinguishable from a + * missed tap. + */ + var addFailure: AddFailure? by mutableStateOf(null) + private set + + /** + * How the last press went, or null before the first one. + * + * Shown on the screen rather than flashed in a snackbar, because it is the + * answer the member came for and it should still be there after the rows + * under it have been read. Cleared when the next press starts. + */ + var lastBroadcast: BroadcastSummary? by mutableStateOf(null) + private set + + private var isSeeded = false + + /** [accepted] of [asked] relays said OK. */ + data class BroadcastSummary(val accepted: Int, val asked: Int) + + enum class AddFailure { + /** Not a `wss://` URL, or one pointing at this machine. */ + NotARelay, + + /** Already in the list, which is a no-op rather than a mistake. */ + AlreadyListed, + } + + /** One relay's answer to one press of the button. */ + sealed interface RelayOutcome { + /** Asked, and not yet answered. */ + data object Sending : RelayOutcome + + /** The relay's OK: it holds the event now, or already did. */ + data object Accepted : RelayOutcome + + /** The relay answered and said no, in its own words. */ + data class Rejected(val reason: String) : RelayOutcome + + /** The relay could not be asked: no connection, or one that dropped. */ + data class Failed(val reason: String) : RelayOutcome + + /** Asked, and still unanswered when the whole broadcast ran out of time. */ + data object NoAnswer : RelayOutcome + } + + fun initiateBroadcastGroupSignedEvent() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + val signedEvent = chatRepository.groupSignedEvent(chatRoomId, signedEventId) + + broadcastGroupSignedEventUIState = when { + localChatRoom == null -> + BroadcastGroupSignedEventUIState.Error("Couldn't find the chat room") + + signedEvent == null -> + BroadcastGroupSignedEventUIState.Error( + "This device doesn't hold that signed event" + ) + + else -> BroadcastGroupSignedEventUIState.Loaded( + localChatRoom = localChatRoom, + signedEvent = signedEvent, + relayLists = chatRepository.groupRelayLists(chatRoomId), + ) + } + } + } + + /** + * Fills the list from what the group has said, once. + * + * Called from the screen rather than from the loader, for the reason the + * relay editor's `seedWorkingCopy` gives: a preview and a layout test hand a + * loaded state straight in. Once and only once, so a member who emptied the + * list on purpose does not find it refilled by a state the view model re-emits. + */ + fun seedRelays(relayLists: List, kind: Kind, tags: Array>, content: String) { + if (isSeeded) return + isSeeded = true + + relays.addAll(defaultRelaysFor(relayLists, kind, tags, content)) + } + + fun clearAddFailure() { + addFailure = null + } + + /** + * Adds [url] to the list, or says why it did not. + * + * True when the row appeared, which is the caller's cue to clear the field. + */ + fun addRelay(url: String): Boolean { + val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run { + addFailure = AddFailure.NotARelay + return false + } + + if (relayUrl in relays) { + addFailure = AddFailure.AlreadyListed + return false + } + + relays.add(relayUrl) + addFailure = null + return true + } + + fun removeRelay(relayUrl: NormalizedRelayUrl) { + relays.remove(relayUrl) + outcomes.remove(relayUrl) + } + + /** + * Sends [signedEvent] to every relay in the list and records what each said. + * + * One call to the pool for the whole list, which opens a socket to any relay + * it does not hold and answers once per relay -- an OK, a rejection with the + * relay's reason, or the error that stopped it being asked. Each answer lands + * in [outcomes] as it arrives, so the rows fill in one by one rather than all + * at the end; a relay that has not answered when the whole thing runs out of + * time is marked so, which is different from having been refused. + * + * [lastBroadcast] sums it up once every row has settled. [onNoRelays] instead + * of a send to nobody: an empty list is a question rather than a mistake, and + * the pool would otherwise fall back to the *member's* relays, which is not + * what an empty list on this screen means. + * + * Returns the send, for a caller that has to wait for it, and null when + * nothing was sent. + */ + fun broadcast( + signedEvent: GroupSignedEvent, + onNoRelays: () -> Unit, + ): Job? { + // Guard against double submits. A second press while the first is out + // would answer each relay's OK twice and count it twice. + if (isActionPending.value) return null + + val targets = relays.toList() + if (targets.isEmpty()) { + onNoRelays.invoke() + return null + } + + isActionPending.value = true + lastBroadcast = null + outcomes.clear() + targets.forEach { outcomes[it] = RelayOutcome.Sending } + + val wireEvent = wireEventOf(signedEvent) + + return viewModelScope.launch(Dispatchers.IO) { + try { + withTimeout(BROADCAST_TIMEOUT) { + publishTransport.publishEvent( + nostrEvent = wireEvent, + relays = targets.map { RelayDTO(url = it.url, read = false, write = true) }.toSet(), + ) + // One answer per relay is all the pool ever gives, and the + // flow underneath never completes on its own. + .take(targets.size) + .collect { result -> record(targets, result) } + } + } catch (e: TimeoutCancellationException) { + // Ordinary: a relay that never answers is what the timeout is + // for. The rows still marked as sending are settled below. + logger.w("Broadcast of ${signedEvent.id} ran out of time") + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // The pool refused the whole call rather than one relay's part of + // it, so every row that was waiting gets the same answer. + logger.e("Broadcast of ${signedEvent.id} failed before any relay answered", e) + targets.forEach { relay -> + if (outcomes[relay] == RelayOutcome.Sending) { + outcomes[relay] = RelayOutcome.Failed(e.message ?: "Could not send") + } + } + } + + targets.forEach { relay -> + if (outcomes[relay] == RelayOutcome.Sending) outcomes[relay] = RelayOutcome.NoAnswer + } + + lastBroadcast = BroadcastSummary( + accepted = targets.count { outcomes[it] == RelayOutcome.Accepted }, + asked = targets.size, + ) + + isActionPending.value = false + } + } + + /** + * Files one relay's answer against the row that asked it. + * + * Matched by host rather than by string, because the pool names a relay by + * the URL it first opened a socket with, and that may carry a trailing slash + * this list's spelling does not. + */ + private fun record(targets: List, result: NostrPublishResult) { + val answered = NormalizedRelayUrl(result.relayUrl).displayUrl() + val relay = targets.firstOrNull { it.displayUrl() == answered } ?: run { + logger.w("Answer from ${result.relayUrl}, which was not asked") + return + } + + val error = result.error + val ok = result.result as? NostrIncomingMessage.OkMessage + + outcomes[relay] = when { + // The pool wraps a relay's ["OK", id, false, reason] in this one + // exception type and nothing else in it, so the message is the + // relay's reason in the relay's words. + error is NostrPublishException -> RelayOutcome.Rejected(error.message ?: "Rejected") + error != null -> RelayOutcome.Failed(error.message ?: "Could not send") + ok?.success == true -> RelayOutcome.Accepted + else -> RelayOutcome.Failed("No OK from the relay") + } + } + + companion object { + private const val TAG = "BroadcastGroupSignedEventViewModel" + + /** + * A ceiling on the whole broadcast, past the pool's own per-relay wait, + * so a socket that opens and then says nothing cannot hold the button + * down forever. The same bound the queue drainer puts on one attempt. + */ + val BROADCAST_TIMEOUT = (RelayPool.PUBLISH_TIMEOUT * 2).milliseconds + + /** + * The event as the pool wants it, and nothing more. + * + * A `NostrEvent` built to be serialised and dropped -- it is never + * written to the table, which is the whole point of this screen sending + * directly rather than through the queue. The seven fields are the ones + * the id was hashed over, so what goes out is what the group signed. + */ + fun wireEventOf(signedEvent: GroupSignedEvent): NostrEvent = NostrEvent( + id = signedEvent.id, + pubKey = signedEvent.publicKey, + kind = signedEvent.kind, + tags = signedEvent.tags, + content = signedEvent.content, + sig = signedEvent.signature, + createdAt = signedEvent.createdAt, + ) + + /** + * Where an event of [kind] should go, before the member has said otherwise. + * + * The group's General list is the answer NIP-65 gives -- its write relays, + * where it has agreed any -- and the app's own publish set stands in for a + * group that has not, since a list of nothing would make the button a + * no-op on every group until somebody edits it. Two kinds add to that: + * + * - A relay list goes to the indexers as well. The list says where to find + * the group, so sending it only to the relays it names is circular -- a + * reader who already knew those relays would not need the list. + * - A curated schema goes to the relays it names for its own replies, so a + * client reading suggestions there finds what they are replying to. + * + * And nothing goes to a relay the group has blocked, whatever the other + * lists say; that is what a blocked list is for. + */ + fun defaultRelaysFor( + relayLists: List, + kind: Kind, + tags: Array> = emptyArray(), + content: String = "", + ): List { + val general = relayLists.firstOrNull { it.set == GroupRelaySet.General } + ?.relays.orEmpty() + .filter { it.write } + .map { it.url } + + val where = general.ifEmpty { Relays.eventPublishRelaySet.toList() } + + val indexers = if (kind in GroupRelaySet.KINDS) { + Relays.DefaultIndexerRelayList.toList() + } else { + emptyList() + } + + val schemaRelays = if (kind == CuratedSchemaEvent.KIND) { + runCatching { CuratedSchemaEvent.read(tags, content).relays } + .getOrDefault(emptyList()) + .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } + } else { + emptyList() + } + + val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked } + ?.relays.orEmpty() + .map { it.url } + .toSet() + + return (where + indexers + schemaRelays).distinct().filterNot { it in blocked } + } + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + signedEventId: HexKey, + initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState = + BroadcastGroupSignedEventUIState.Loading, + chatRepository: ChatRepository, + publishTransport: EventPublishTransport, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + BroadcastGroupSignedEventViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = signedEventId, + initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState, + chatRepository = chatRepository, + publishTransport = publishTransport, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt new file mode 100644 index 00000000..2f7437d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelayList + +sealed interface BroadcastGroupSignedEventUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** The event to send, as the group signed it. What goes on the wire is this, byte for byte. */ + val signedEvent: GroupSignedEvent, + /** + * Where the group has said it lives, which is what the relay picker starts + * from: the General list's write relays when the group has agreed one, and + * never a relay the group has blocked. See + * `BroadcastGroupSignedEventViewModel.defaultRelaysFor`. + */ + val relayLists: List = emptyList(), + ): BroadcastGroupSignedEventUIState + + data class Error( + val message: String + ): BroadcastGroupSignedEventUIState + + data object Loading: BroadcastGroupSignedEventUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt new file mode 100644 index 00000000..327f695d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt @@ -0,0 +1,240 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runDesktopComposeUiTest +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.exceptions.NostrPublishException +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.network.relays.NostrPublishResult +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * What the broadcast screen shows before, and after, the button. + * + * Before: the event named as the signing screen would name it, the relays the + * group has said it lives at already in the list, and a way to add one. After: + * each relay's answer under its own row, in the relay's words where it gave + * any, and the sum above them -- because the sum is what the member came for and + * the words are what they can act on. + */ +@OptIn(ExperimentalTestApi::class) +class BroadcastGroupSignedEventScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + + /** A pool that answers as told and never opens a socket. */ + private class FakeTransport( + private val answers: List + ) : EventPublishTransport { + override suspend fun publishEvent( + nostrEvent: NostrEvent, + relays: Set + ): Flow = flowOf(*answers.toTypedArray()) + } + + @Test + fun `the event is named, the group's relays are filled in, and the JSON is there to copy`() = + render(state()) { + onNodeWithText("Post").assertIsDisplayed() + onNodeWithText("The first chapter is out.").assertIsDisplayed() + + // The group's own General list, as hosts. + onNodeWithText("relay.one.example").assertIsDisplayed() + onNodeWithText("relay.two.example").assertIsDisplayed() + + onNodeWithText("Broadcast").assertIsDisplayed() + onNodeWithText("Copy raw JSON").assertIsDisplayed() + + // The relays come before the JSON: the list is the screen's job and + // the JSON is for whoever would rather use a different tool. + val relays = onNodeWithText("relay.one.example").getBoundsInRoot().top + val json = onNodeWithText("The signed event").getBoundsInRoot().top + assertTrue(relays < json, "the JSON climbed above the relay list") + } + + @Test + fun `a relay can be added, and a bad one is refused by name`() = render(state()) { + onNodeWithText("Relay url").performTextInput("wss://relay.three.example") + onNodeWithContentDescription("Add relay").performClick() + onNodeWithText("relay.three.example").assertIsDisplayed() + + onNodeWithText("Relay url").performTextInput("not a relay") + onNodeWithContentDescription("Add relay").performClick() + onNodeWithText("That isn't a relay address.", substring = true).assertIsDisplayed() + } + + @Test + fun `after the button, each relay says what it answered and the sum sits above them`() = + render( + uiState = state(), + transport = FakeTransport( + listOf( + NostrPublishResult( + result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true), + relayUrl = one.url, + ), + NostrPublishResult( + result = NostrIncomingMessage.OkMessage( + eventId = "b".repeat(64), + success = false, + message = "blocked: not on the allow list", + ), + error = NostrPublishException(message = "blocked: not on the allow list"), + relayUrl = two.url, + ), + ) + ) + ) { + onNodeWithContentDescription("Broadcast to these relays").performClick() + + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText("Accepted by 1 of 2 relays.").fetchSemanticsNodes().isNotEmpty() + } + + onNodeWithText("Accepted").assertIsDisplayed() + onNodeWithText("Rejected: blocked: not on the allow list").assertIsDisplayed() + + // The answer under the relay it came from, not in a list of its own. + val relayOne = onNodeWithText("relay.one.example").getBoundsInRoot().top + val accepted = onNodeWithText("Accepted").getBoundsInRoot().top + val relayTwo = onNodeWithText("relay.two.example").getBoundsInRoot().top + val rejected = onNodeWithText("Rejected: blocked: not on the allow list").getBoundsInRoot().top + assertTrue(relayOne < accepted && accepted < relayTwo, "the OK is not under its relay") + assertTrue(relayTwo < rejected, "the rejection is not under its relay") + } + + @Test + fun `an empty list asks nobody and says to add one`() = render( + state(relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId), seedNothing = true) + ) { + onNodeWithText("No relays to broadcast to. Add one above.").assertIsDisplayed() + } + + private fun signedEvent() = GroupSignedEvent( + id = "b".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "The first chapter is out.", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + /** A General list of two relays, agreed, so the screen has something of the group's to fill in. */ + private fun agreedRelayLists(): List = + GroupRelayList.allAmong(emptyList(), chatRoomId).map { list -> + if (list.set != GroupRelaySet.General) return@map list + + GroupRelayList( + set = list.set, + signedEvent = GroupSignedEvent( + id = "a".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = list.set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + relays = listOf(GroupRelay(one), GroupRelay(two)), + ) + } + + /** + * [seedNothing] blocks every relay the seed would otherwise fall back to, so + * the screen can be seen with an empty list -- a group that has agreed + * nothing is filled in from the app's own set, which is the point of the + * fallback and the opposite of what this fixture wants. + */ + private fun state( + relayLists: List = agreedRelayLists(), + seedNothing: Boolean = false, + ) = BroadcastGroupSignedEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + signedEvent = signedEvent(), + relayLists = if (seedNothing) { + relayLists.map { list -> + if (list.set != GroupRelaySet.Blocked) return@map list + + GroupRelayList( + set = list.set, + signedEvent = null, + relays = Relays.eventPublishRelaySet.map { GroupRelay(it) }, + ) + } + } else { + relayLists + }, + ) + + private fun render( + uiState: BroadcastGroupSignedEventUIState, + transport: EventPublishTransport = FakeTransport(emptyList()), + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 1600) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + BroadcastGroupSignedEventScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = "b".repeat(64), + initialBroadcastGroupSignedEventUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + publishTransport = transport, + onNavigateBack = {} + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index b89d56eb..733dd80a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -281,6 +281,57 @@ class GroupNostrProfileSectionJvmTest { assertTrue(books.top < second.top, "the second queue button is not under its card") } + @Test + fun `every signed event in the block offers a broadcast, under its own card`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + posts = listOf(post("Something the group said", 1_700_000_000)), + schemas = listOf(schema("films", "Films worth translating", 1_700_000_900)) + ) + ) { + // One per signed event: the profile, the two relay lists the group has + // agreed -- the withdrawn one included, since a withdrawal is a statement + // relays still holding the old list need to hear -- the post and the + // schema. Not the two lists the group has never agreed, which are not + // events. + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(5) + + // Under the card it sends, in the order the block lays them out. The + // profile card is found by its address, since its name is also the + // post's byline. + val profileCard = onNodeWithText("group@example.com").getBoundsInRoot().top + val relays = onNodeWithText("Relays").getBoundsInRoot().top + val general = onNodeWithText("General").getBoundsInRoot().top + val blocked = onNodeWithText("Blocked").getBoundsInRoot().top + val postCard = onNodeWithText("Something the group said").getBoundsInRoot().top + val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top + val tops = (0 until 5).map { buttons[it].getBoundsInRoot().top } + + assertTrue(profileCard < tops[0] && tops[0] < relays, "the profile's broadcast is not under its card") + assertTrue(general < tops[1] && tops[1] < blocked, "the general list's broadcast is not under its row") + assertTrue(blocked < tops[2] && tops[2] < postCard, "the blocked list's broadcast is not under its row") + assertTrue(postCard < tops[3] && tops[3] < schemaCard, "the post's broadcast is not under its card") + assertTrue(schemaCard < tops[4], "the schema's broadcast is not under its card") + } + + @Test + fun `a member holding no share of the key may still broadcast what the group signed`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + posts = listOf(post("Something the group said", 1_700_000_000)), + canEditNostrProfile = false + ) + ) { + // Sending what is already signed takes no share of the key -- the + // signature is the group's whoever repeats it -- so this is the one + // action in the block, with the queue, that the gate does not cover. + onAllNodesWithText("Broadcast").assertCountEquals(4) + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + } + @Test fun `a member holding a share of the key may propose any event the block can show`() = render( state(groupNostrProfile = profile, relayLists = signedRelayLists()) @@ -342,6 +393,7 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Curated schemas").assertDoesNotExist() onNodeWithText("Add schema").assertDoesNotExist() onNodeWithText("Propose event").assertDoesNotExist() + onAllNodesWithText("Broadcast").assertCountEquals(0) // The rest of the screen is untouched, so the section's absence is an // absence rather than a screen that failed to draw. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt new file mode 100644 index 00000000..64b36d13 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt @@ -0,0 +1,370 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.jsonPrimitive +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.exceptions.NostrPublishException +import press.mantra.compose.network.dto.RelayDTO +import press.mantra.compose.network.relays.EventPublishTransport +import press.mantra.compose.network.relays.NostrPublishResult +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome +import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The three decisions the broadcast screen makes that nothing else can check. + * + * **Where an event goes before anyone says otherwise.** The seeded list is what a + * member who reads nothing and presses the button sends to, so it has to be the + * group's own answer where the group has given one, has to leave out what the + * group has blocked, and has to widen for the two kinds whose readers would not + * otherwise find them. + * + * **What each relay's answer becomes.** The pool answers once per relay in three + * shapes -- an OK, a rejection wrapped in one exception type, and any other error + * -- and the rows show them as three different things because a member can act + * on "blocked: not on the allow list" and cannot act on a red icon. + * + * **What goes on the wire.** The seven fields the group hashed and nothing else, + * so a relay checking the id and the signature finds the event the group signed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BroadcastGroupSignedEventViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + private val three = RelayUrlNormalizer.normalize("wss://relay.three.example") + + /** Answers each relay with whatever [answers] says, keyed by host, and remembers what it was handed. */ + private class FakeTransport( + private val answers: Map + ) : EventPublishTransport { + var sent: NostrEvent? = null + var askedRelays: Set = emptySet() + + override suspend fun publishEvent( + nostrEvent: NostrEvent, + relays: Set + ): Flow { + sent = nostrEvent + askedRelays = relays + return flowOf(*answers.values.toTypedArray()) + } + } + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(transport: EventPublishTransport = FakeTransport(emptyMap())) = + BroadcastGroupSignedEventViewModel( + chatRoomId = chatRoomId, + signedEventId = "b".repeat(64), + activeUserPublicKey = "a".repeat(64), + relayHint = null, + initialBroadcastGroupSignedEventUIState = BroadcastGroupSignedEventUIState.Loading, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + publishTransport = transport, + ) + + private fun signedEvent(kind: Int = TextNoteEvent.KIND, tags: Array> = emptyArray()) = + GroupSignedEvent( + id = "b".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = kind, + tags = tags, + content = "The first chapter is out.", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent( + id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + /** Every set unsigned, which is a group that has never said where it lives. */ + private fun nothingAgreed() = GroupRelayList.allAmong(emptyList(), chatRoomId) + + private fun agreed(vararg lists: Pair>) = + nothingAgreed().map { list -> + lists.firstOrNull { it.first == list.set } + ?.let { GroupRelayList(list.set, relaySignedEvent(list.set), it.second) } + ?: list + } + + private fun ok(relay: NormalizedRelayUrl) = NostrPublishResult( + result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true), + relayUrl = relay.url, + ) + + private fun rejected(relay: NormalizedRelayUrl, reason: String) = NostrPublishResult( + result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = false, message = reason), + error = NostrPublishException(message = reason), + relayUrl = relay.url, + ) + + private fun failed(relay: NormalizedRelayUrl, reason: String) = NostrPublishResult( + error = IllegalStateException(reason), + relayUrl = relay.url, + ) + + // -- where an event goes before anyone says otherwise ------------------------ + + @Test + fun `a group that has not said where it lives sends to the app's publish set`() { + assertEquals( + Relays.eventPublishRelaySet.toList(), + BroadcastGroupSignedEventViewModel.defaultRelaysFor(nothingAgreed(), TextNoteEvent.KIND), + ) + } + + @Test + fun `a group's own general list wins, and only the relays it writes to`() { + val relayLists = agreed( + GroupRelaySet.General to listOf( + GroupRelay(one), + // Read-only: where the group looks, not where it puts things. + GroupRelay(two, read = true, write = false), + ) + ) + + // The group's answer and nothing from the app's set beside it: a member + // who edited the relays meant them, and padding the list would send the + // group's post somewhere the group decided not to be. + assertEquals( + listOf(one), + BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND), + ) + } + + @Test + fun `a blocked relay is dropped whatever the other lists say`() { + val relayLists = agreed( + GroupRelaySet.General to listOf(GroupRelay(one), GroupRelay(two)), + GroupRelaySet.Blocked to listOf(GroupRelay(two)), + ) + + assertEquals( + listOf(one), + BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND), + ) + } + + @Test + fun `a relay list also goes to the indexers, since its readers do not yet know the relays it names`() { + val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) + + val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor( + relayLists, AdvertisedRelayListEvent.KIND + ) + + assertEquals(one, seeded.first()) + Relays.DefaultIndexerRelayList.forEach { indexer -> + assertTrue(indexer in seeded, "the relay list would not reach ${indexer.url}") + } + + // And a post does not: nobody looks an indexer up for a kind 1. + val post = BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND) + Relays.DefaultIndexerRelayList.forEach { indexer -> + assertFalse(indexer in post, "a post was sent to the indexer ${indexer.url}") + } + } + + @Test + fun `a curated schema also goes to the relays it names for its own replies`() { + val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) + + val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor( + relayLists = relayLists, + kind = CuratedSchemaEvent.KIND, + tags = arrayOf( + arrayOf("d", "films"), + arrayOf("relay", three.url), + ), + content = "Films worth translating.", + ) + + // The group's relay first, then the list's own -- a client reading + // suggestions on the list's relay finds the schema they answer. + assertEquals(listOf(one, three), seeded) + } + + @Test + fun `seeding happens once, so an emptied list stays empty`() { + val viewModel = viewModel() + val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) + val event = signedEvent() + + viewModel.seedRelays(relayLists, event.kind, event.tags, event.content) + viewModel.seedRelays(relayLists, event.kind, event.tags, event.content) + assertEquals(listOf(one), viewModel.relays.toList()) + + viewModel.removeRelay(one) + viewModel.seedRelays(relayLists, event.kind, event.tags, event.content) + assertEquals(emptyList(), viewModel.relays.toList()) + } + + // -- what may be added ---------------------------------------------------------- + + @Test + fun `an add is refused by name, and a good one clears the refusal`() { + val viewModel = viewModel() + + assertFalse(viewModel.addRelay("https://not-a-relay.example")) + assertEquals(BroadcastGroupSignedEventViewModel.AddFailure.NotARelay, viewModel.addFailure) + + assertTrue(viewModel.addRelay(one.url)) + assertNull(viewModel.addFailure) + + assertFalse(viewModel.addRelay(one.url)) + assertEquals(BroadcastGroupSignedEventViewModel.AddFailure.AlreadyListed, viewModel.addFailure) + + assertEquals(listOf(one), viewModel.relays.toList()) + } + + // -- what each relay's answer becomes --------------------------------------------- + + @Test + fun `each relay's answer lands on its own row, and the sum counts only the OKs`() { + val transport = FakeTransport( + mapOf( + "one" to ok(one), + "two" to rejected(two, "blocked: not on the allow list"), + "three" to failed(three, "Connection refused"), + ) + ) + val viewModel = viewModel(transport) + listOf(one, two, three).forEach { assertTrue(viewModel.addRelay(it.url)) } + + var toldNoRelays = false + val job = viewModel.broadcast(signedEvent(), onNoRelays = { toldNoRelays = true }) + runBlocking { withTimeout(5_000) { job!!.join() } } + + assertFalse(toldNoRelays) + assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one]) + assertEquals(RelayOutcome.Rejected("blocked: not on the allow list"), viewModel.outcomes[two]) + assertEquals(RelayOutcome.Failed("Connection refused"), viewModel.outcomes[three]) + assertEquals( + BroadcastGroupSignedEventViewModel.BroadcastSummary(accepted = 1, asked = 3), + viewModel.lastBroadcast, + ) + assertFalse(viewModel.isActionPending.value) + + // Asked as write relays: the pool would otherwise read from them too. + assertEquals( + setOf(one, two, three).map { RelayDTO(url = it.url, read = false, write = true) }.toSet(), + transport.askedRelays, + ) + } + + @Test + fun `an answer is matched by host, since the pool may spell the relay with a trailing slash`() { + val transport = FakeTransport( + mapOf("one" to ok(one).copy(relayUrl = one.url.trimEnd('/') + "/")) + ) + val viewModel = viewModel(transport) + assertTrue(viewModel.addRelay(one.url)) + + val job = viewModel.broadcast(signedEvent(), onNoRelays = {}) + runBlocking { withTimeout(5_000) { job!!.join() } } + + assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one]) + } + + @Test + fun `a relay that never answers is marked so, which is not the same as refused`() { + // The pool answers for one relay and then the flow ends, which is what a + // send that ran out of time looks like from here. + val transport = FakeTransport(mapOf("one" to ok(one))) + val viewModel = viewModel(transport) + assertTrue(viewModel.addRelay(one.url)) + assertTrue(viewModel.addRelay(two.url)) + + val job = viewModel.broadcast(signedEvent(), onNoRelays = {}) + runBlocking { withTimeout(5_000) { job!!.join() } } + + assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one]) + assertEquals(RelayOutcome.NoAnswer, viewModel.outcomes[two]) + assertEquals( + BroadcastGroupSignedEventViewModel.BroadcastSummary(accepted = 1, asked = 2), + viewModel.lastBroadcast, + ) + } + + @Test + fun `an empty list asks nobody and says so`() { + val transport = FakeTransport(emptyMap()) + val viewModel = viewModel(transport) + + var toldNoRelays = false + val job = viewModel.broadcast(signedEvent(), onNoRelays = { toldNoRelays = true }) + + assertNull(job) + assertTrue(toldNoRelays) + assertNull(transport.sent) + assertFalse(viewModel.isActionPending.value) + } + + // -- what goes on the wire ------------------------------------------------------------ + + @Test + fun `what goes on the wire is the event the group signed, field for field`() { + val event = signedEvent( + kind = MetadataEvent.KIND, + tags = arrayOf(arrayOf("alt", "The group's profile")), + ) + + val wire = BroadcastGroupSignedEventViewModel.wireEventOf(event).toNostrJsonObject() + + assertEquals(event.id, wire["id"]!!.jsonPrimitive.content) + assertEquals(event.publicKey, wire["pubkey"]!!.jsonPrimitive.content) + assertEquals(event.createdAt.epochSeconds.toString(), wire["created_at"]!!.jsonPrimitive.content) + assertEquals(event.kind.toString(), wire["kind"]!!.jsonPrimitive.content) + assertEquals(event.content, wire["content"]!!.jsonPrimitive.content) + assertEquals(event.signature, wire["sig"]!!.jsonPrimitive.content) + assertEquals("""[["alt","The group's profile"]]""", wire["tags"].toString()) + } +} From 8d32a69bf32bcd1322aae19d5548cba5edc2fb0d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 15:23:17 +0200 Subject: [PATCH 23/71] feat(groups): accept a suggestion into the list, edited, as an entry for the group to sign The queue's sheet used to end in the suggestion's JSON and a button to copy it. Now it ends in the one thing a member can do about a suggestion: take it into the list. That opens a form seeded from the suggestion -- an input per field the schema asks for, enums as chips, one input per value where a field repeats -- and a button that proposes a kind 31890 for the room's quorum to sign, pointing back at the suggestion it came from. The write side of the NIP is ported for it: CuratedEntryEvent.canonicalTemplate follows bitcoin.mov's buildCuratedCanonicalTemplate tag for tag, and walks the same field list as the reader, so what the form proposes is what the queue reads back. The one deviation is that nothing is clipped on the way through; a value the schema refuses is named under its input instead, by the queue's own verifier, before a quorum is spent on it. The d is kept whatever field writes it, so accepting twice revises one entry rather than making two, and derived fields are kept as the suggester's client filled them in, since this app derives nothing. After the quorum signs, a 31890 has somewhere to land: the signing screen says which entry and who suggested it, the transcript gets a line naming the list, and the broadcast screen seeds the schema's relays, which the NIP makes a MUST and which the entry only names by coordinate. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@fcc19f950063ff72e2864f74066d56eb23252b1d --- .../composeResources/values/strings.xml | 30 +- .../compose/database/model/ChatMessage.kt | 55 ++ .../mantra/compose/nostr/CuratedSuggestion.kt | 2 +- .../compose/nostr/GroupCuratedSchema.kt | 48 + .../nostr/curated/CuratedEntryEvent.kt | 168 +++- .../mantra/compose/text/ProposedEvent.kt | 17 + .../AcceptCuratedSuggestionScreen.kt | 843 ++++++++++++++++++ .../BroadcastGroupSignedEventScreen.kt | 1 + .../composable/CuratedSuggestionListScreen.kt | 111 ++- .../ui/composable/navigation/MantraNavHost.kt | 31 + .../routes/AcceptCuratedSuggestionRoute.kt | 22 + .../model/AcceptCuratedSuggestionViewModel.kt | 324 +++++++ .../BroadcastGroupSignedEventViewModel.kt | 44 +- .../state/AcceptCuratedSuggestionUIState.kt | 34 + .../state/BroadcastGroupSignedEventUIState.kt | 7 + .../nostr/curated/CuratedEntryEventTest.kt | 164 ++++ .../mantra/compose/text/ProposedEventTest.kt | 34 + .../AcceptCuratedSuggestionScreenJvmTest.kt | 216 +++++ .../CuratedSuggestionListScreenJvmTest.kt | 43 +- ...AcceptCuratedSuggestionViewModelJvmTest.kt | 412 +++++++++ ...oadcastGroupSignedEventViewModelJvmTest.kt | 50 ++ 21 files changed, 2571 insertions(+), 85 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c85da50e..4644ca10 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -583,8 +583,34 @@ Curated Suggested by %1$s on %2$s Taken up by the group into the list. - Copy the suggestion - Copied the suggestion + Accept this suggestion + Revise the list's entry + + Take this suggestion into %1$s, edited as the group wants it. The entry keeps the suggestion's identifier and points back at it, so the suggester is credited. + The group signs an entry, so it takes a quorum. Nothing changes until one has. + This group has no shared key, so it cannot sign an entry. Run a shared key ceremony first. + The group already has an entry for this. Proposing again revises it in place rather than adding a second one. + Propose entry + Couldn't ask the group to sign this entry. Please try again. + This entry can't be signed as it is: + The entry can't be signed as it is. + Kept from the suggestion + Filled in by the suggester's app and kept as they came, the identifier among them, so that an entry accepted twice lands on the same coordinate. + Add another + This field is required. + Only one value for this field. + One word, without spaces. + That is not a web address. + That must be an https address. + Pick one of the options. + A whole number. + More than zero. + At least %1$s. + At most %1$s. + At most %1$s characters. + Must match %1$s. + At least one of these is needed: %1$s. + %1$s: %2$s Broadcast Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 539ee32a..8221ba82 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -447,11 +448,22 @@ data class ChatMessage( */ const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned" + /** + * The group signed an entry into a list it curates. + * + * A system line like the schema's, and it names the entry and the list + * rather than the fields: what a reader of the room needs to know is + * that the list now stands behind a title, and the entry itself is on + * the queue screen where its fields can be read. + */ + const val TYPE_GROUP_ENTRY_CURATED = "groupEntryCurated" + val GROUP_IDENTITY_TYPES = setOf( TYPE_GROUP_PROFILE_SIGNED, TYPE_GROUP_RELAYS_SIGNED, TYPE_GROUP_POST_SIGNED, TYPE_GROUP_SCHEMA_SIGNED, + TYPE_GROUP_ENTRY_CURATED, ) /** @@ -1579,6 +1591,49 @@ data class ChatMessage( ) } + // The group taking an entry into a list it curates: the kind + // 31890 that only its own key can publish, and the thing the + // queue screen marks a suggestion "curated" by once it is read + // back off a relay. + // + // Verified, for the reason the schema arm is: a rumor of this + // kind from a member is not the group curating anything. Named + // after the list where this device holds its schema -- looked + // up by the coordinate the entry replies to -- and after the + // entry alone where it does not, since the entry is what was + // signed either way. + CuratedEntryEvent.CANONICAL_KIND -> { + val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) + if (!signed.verifies()) return null + + val entry = CuratedEntryEvent.headline(event) + val title = entry.title.ifBlank { entry.identifier }.ifBlank { "an entry" } + + val list = runCatching { + GroupCuratedSchema.newestPerListAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(groupId, CuratedSchemaEvent.KIND), + chatRoomId = groupId, + ).firstOrNull { it.isRepliedToBy(event.tags) } + }.getOrNull() + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_ENTRY_CURATED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = if (list != null) { + "The group accepted $title into ${list.schema.displayName()}" + } else { + "The group accepted $title into a list it curates" + } + ) + } + else -> unsupported() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt index 67fcdb00..a7c1ff88 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt @@ -34,7 +34,7 @@ import kotlin.time.Instant data class CuratedSuggestion( /** The entry, keyed by the schema's field names. */ val entry: CuratedEntry, - /** The event as it was stored, for the id, the tags, and copying it somewhere. */ + /** The event as it was stored, for the id and the tags: what a canonical entry points back at. */ val event: NostrEvent, /** The suggester's profile where one has arrived, else null. */ val suggester: Profile?, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt index 1d6727d8..1622e453 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt @@ -2,8 +2,11 @@ package press.mantra.compose.nostr import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedEntryProblem import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSuggestionRef import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlin.time.Instant @@ -57,6 +60,14 @@ data class GroupCuratedSchema( /** What a suggestion's `a` root names to reply to this list. */ fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier) + /** + * Whether an entry carrying [tags] replies to this list: its `a` tags + * include this coordinate. How a reader holding several of the group's + * schemas tells which one an entry belongs to. + */ + fun isRepliedToBy(tags: Array>): Boolean = + tags.any { it.size > 1 && it[0] == COORDINATE_TAG && it[1] == coordinate() } + /** * What to ask a relay for to read this list's queue: the NIP's two queries, * as one request. @@ -93,6 +104,43 @@ data class GroupCuratedSchema( tags = mapOf(COORDINATE_TAG to listOf(coordinate())), ) + /** + * The event to ask the group to sign to take an entry into the list: + * [values] as this schema lays them out, rooted at [coordinate], with the + * group as the curator and [source] as the suggestion it came from. + * + * The group is the curator, so its key is the `p` on the root and the only + * author [verifyCanonical] will accept. See [CuratedEntryEvent.canonicalTemplate] + * for what is and is not done to the values on the way through. + */ + fun canonicalTemplate( + values: Map>, + source: CuratedSuggestionRef?, + ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate( + schema = schema, + coordinate = coordinate(), + curator = publicKey, + values = values, + source = source, + ) + + /** + * Every reason this list's readers would refuse [template] as one of its + * own entries, or nothing. + * + * The same check `CuratedSuggestion.queueOf` makes on a kind 31890 off a + * relay, made before the group is asked to sign one: a signature is the + * most expensive thing this app does, and an entry the queue would then + * refuse to count is a quorum spent on nothing. + */ + fun verifyCanonical(template: EventTemplate<*>): List = + CuratedEntryEvent.verifyCanonical( + event = CuratedEntryEvent.preview(template, publicKey), + schema = schema, + coordinate = coordinate(), + curator = publicKey, + ) + companion object { /** The tag a reply carries its schema's coordinate in. */ private const val COORDINATE_TAG = "a" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt index 340239f4..5543e0aa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt @@ -3,6 +3,8 @@ package press.mantra.compose.nostr.curated import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.time.Clock /** * Kinds 31888 and 31890: an entry in a curated list, as somebody suggested it and @@ -18,7 +20,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind * This is the other half of [CuratedSchemaEvent]: that is what a list *asks* * for, this is what it *gets*. Ported from the reference module's `verifyEntry` * rule for rule, so that an entry bitcoin.mov accepts is one this app accepts - * and an entry it refuses is refused here for the same reason. + * and an entry it refuses is refused here for the same reason. The write side, + * [canonicalTemplate], is the port of its `buildCuratedCanonicalTemplate`, and + * walks the same field list as [read] so the two cannot drift. * * ### Rejected, not repaired * @@ -40,6 +44,12 @@ object CuratedEntryEvent { private const val TAG_EVENT = "e" private const val TAG_IDENTIFIER = "d" private const val TAG_TITLE = "title" + private const val TAG_PUBKEY = "p" + private const val TAG_KIND = "k" + + /** NIP-10's markers: the schema being replied to, and the suggestion being credited. */ + private const val MARKER_ROOT = "root" + private const val MARKER_MENTION = "mention" /** What every schema coordinate starts with, which is how a root is told from a source. */ private val SCHEMA_PREFIX = "${CuratedSchemaEvent.KIND}:" @@ -87,6 +97,20 @@ object CuratedEntryEvent { ) } + /** + * The parts of an entry that need no schema to read: its identifier, its + * title and, for a canonical entry, where it came from. + * + * For a reader that has the event and not the list -- the signing screen, + * the transcript -- and wants to say which entry this is rather than check + * it. [CuratedEntry.values] is empty, since without a schema there are no + * fields to key them by. + */ + fun headline(event: Event): CuratedEntry = read(event, NO_SCHEMA) + + /** A schema with no fields, for [headline]: the two lifted tags are read regardless. */ + private val NO_SCHEMA = CuratedSchema(identifier = "", title = "", name = "", description = "", visibility = null) + /** * Every reason [event] is not a suggestion to the list at [coordinate], or * nothing. @@ -128,6 +152,128 @@ object CuratedEntryEvent { } } + /** + * The unsigned canonical entry for the curator to sign: [values] laid out as + * the schema's tags, rooted at the schema's [coordinate], and pointing back + * at [source] when it came from a suggestion. + * + * The reverse of [read], and the reason a canonical entry and the suggestion + * it came from are one shape: both walk the schema's one field list, so what + * this writes is what [canonical] reads back, field for field. Ported from + * the reference module's `buildCuratedCanonicalTemplate` tag for tag, so + * that an entry this app signs is one bitcoin.mov's list shows. + * + * [values] is keyed by field name the way [CuratedEntry.values] is, derived + * fields included. The reference client derives `d` and `t` for a suggestion + * it is composing; this app composes none, and a suggestion it accepts keeps + * what the suggester's client derived -- the `d` above all, which the NIP + * says a canonical entry SHOULD keep so that re-curating revises the entry + * in place rather than adding a second one. Nothing is clipped or repaired + * on the way through: a value the schema refuses is refused by + * [verifyCanonical] on the result, with the field named, which is what a + * form wants to show. A blank value is not a value and writes nothing, and a + * field without `repeat` writes its first value only. + * + * [relayHint] goes on the root, per NIP-10: where a reader who does not know + * the list can find the schema being replied to. The NIP says it SHOULD be + * the first of the schema's relays, and by default it is. + */ + fun canonicalTemplate( + schema: CuratedSchema, + coordinate: String, + curator: HexKey, + values: Map>, + source: CuratedSuggestionRef? = null, + relayHint: String? = schema.relays.firstOrNull(), + createdAt: Long = Clock.System.now().epochSeconds, + ): EventTemplate { + val (fieldTags, content) = fieldTags(schema, values) + + return EventTemplate( + createdAt = createdAt, + kind = CANONICAL_KIND, + tags = (fieldTags + replyTags(coordinate, curator, relayHint) + sourceTags(source)).toTypedArray(), + content = content, + ) + } + + /** + * [template] as an event the verifiers can read: the kind, the tags, the + * content and [pubKey], which is everything they look at, over a blank id + * and signature, which they do not. For checking what is about to be + * proposed before a quorum is spent on it. + */ + fun preview(template: EventTemplate<*>, pubKey: HexKey): Event = Event( + id = "", + pubKey = pubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + + /** + * The tags that make an entry a reply to its schema, and nothing else. + * + * The root is the schema's coordinate rather than an event id, because the + * kind is addressable: revising the schema mints a new id and keeps the + * coordinate, and an entry pinned to an id would be orphaned by the first + * edit. `p` and `k` are the reply courtesies the NIP asks for and nothing + * verifies; `a` is what is verified and what relays are queried on. + */ + fun replyTags(coordinate: String, curator: HexKey, relayHint: String?): List> = listOf( + arrayOf(TAG_ADDRESS, coordinate, relayHint ?: "", MARKER_ROOT), + arrayOf(TAG_PUBKEY, curator), + arrayOf(TAG_KIND, CuratedSchemaEvent.KIND.toString()), + ) + + /** + * The values as the schema's tags: one per value at the field's tag, with + * its marker as the third element when it has one, or the body for the + * field that writes there. + */ + private fun fieldTags(schema: CuratedSchema, values: Map>): Pair>, String> { + val tags = mutableListOf>() + var content = "" + + for (field in schema.fields) { + val tag = field.tag() + val marker = field.config.marker + + for (value in values[field.name].orEmpty()) { + val trimmed = value.trim() + if (trimmed.isEmpty()) continue + + if (tag == CuratedFieldConfig.CONTENT_TAG) { + content = trimmed + } else { + tags.add(if (marker != null) arrayOf(tag, trimmed, marker) else arrayOf(tag, trimmed)) + } + + if (field.config.repeat != true) break + } + } + + return tags to content + } + + /** + * Both halves of the pointer back: the coordinate, which follows the + * suggester's later edits, and the id, which pins what the curator actually + * read. They answer different questions, which is why the NIP has both. The + * `p` credits the suggester. + */ + private fun sourceTags(source: CuratedSuggestionRef?): List> { + if (source == null) return emptyList() + + return buildList { + add(arrayOf(TAG_ADDRESS, source.coordinate, "", MARKER_MENTION)) + source.id?.let { add(arrayOf(TAG_EVENT, it, "", MARKER_MENTION)) } + add(arrayOf(TAG_PUBKEY, source.pubKey)) + } + } + /** * One value against its field's type and config, or the first thing wrong * with it. @@ -359,7 +505,25 @@ data class CuratedSuggestionRef( val coordinate: String, /** The suggester, credited. */ val pubKey: HexKey, -) +) { + companion object { + /** + * The pointer a canonical entry made from [entry] carries: its id, its + * coordinate and its author. Only a suggestion can be pointed at -- a + * canonical entry is the list's own word, and the list did not suggest + * anything to itself. + */ + fun of(entry: CuratedEntry): CuratedSuggestionRef { + require(!entry.isCanonical) { "A canonical entry is not the source of another" } + + return CuratedSuggestionRef( + id = entry.id, + coordinate = entry.coordinate(), + pubKey = entry.pubKey, + ) + } + } +} /** * One reason an entry does not satisfy its schema: which field, and what about diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index fe9c10f3..926ab6c5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -3,8 +3,10 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import press.mantra.compose.extensions.shortened import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -208,6 +210,21 @@ object ProposedEvent { } ) + // An entry the group would take into one of its lists. A member signing + // this is agreeing that the list stands behind this title, so the + // summary is the title and who suggested it -- the credit the entry + // carries -- and not its fields, which the form showed whole before the + // proposal was made and the queue shows again once it lands. + CuratedEntryEvent.CANONICAL_KIND -> Summary( + label = "Curated entry", + detail = CuratedEntryEvent.headline(event).let { entry -> + listOfNotNull( + entry.title.ifBlank { entry.identifier }.ifBlank { null }, + entry.source?.let { "suggested by ${it.pubKey.shortened()}" } + ).joinToString(" · ").ifEmpty { "An untitled entry" } + } + ) + else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt new file mode 100644 index 00000000..9b21092f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt @@ -0,0 +1,843 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_whole_number +import mantra.composeapp.generated.resources.add_another +import mantra.composeapp.generated.resources.at_least_n +import mantra.composeapp.generated.resources.at_least_one_of_these_is_needed +import mantra.composeapp.generated.resources.at_most_n +import mantra.composeapp.generated.resources.at_most_n_characters +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_entry +import mantra.composeapp.generated.resources.field_and_problem +import mantra.composeapp.generated.resources.filled_in_by_the_suggester_s_app_and_kept +import mantra.composeapp.generated.resources.kept_from_the_suggestion +import mantra.composeapp.generated.resources.more_than_zero +import mantra.composeapp.generated.resources.must_match_the_pattern +import mantra.composeapp.generated.resources.one_word_without_spaces +import mantra.composeapp.generated.resources.only_one_value_for_this_field +import mantra.composeapp.generated.resources.pick_one_of_the_options +import mantra.composeapp.generated.resources.propose_entry +import mantra.composeapp.generated.resources.suggested_by_on +import mantra.composeapp.generated.resources.take_this_suggestion_into_the_list +import mantra.composeapp.generated.resources.that_is_not_a_web_address +import mantra.composeapp.generated.resources.that_must_be_an_https_address +import mantra.composeapp.generated.resources.the_entry_cannot_be_signed_as_it_is +import mantra.composeapp.generated.resources.the_group_already_has_an_entry_for_this +import mantra.composeapp.generated.resources.the_group_signs_an_entry_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_entry_cannot_be_signed_as_it_is +import mantra.composeapp.generated.resources.this_field_is_required +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_entry +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryProblem +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AcceptCuratedSuggestionViewModel +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState +import kotlin.time.Instant + +/** + * The form for taking one suggestion into a list the group curates. + * + * The queue screen shows what was suggested; this is where the group decides + * what the list will say. Every field the schema's form asks for is an input + * seeded from the suggestion, because curation is editorial -- the NIP lets + * the curator fix a year or swap a poster on the way through, and a button + * that could only say yes would leave a typo in the list forever. What the + * form does not ask for it shows: the identifier, which the entry keeps so + * that accepting it twice revises one entry rather than making two, and the + * fields the suggester's app filled in, which this app has no way to fill in + * itself. + * + * **The button proposes rather than saves.** The entry goes out as one kind + * 31890 for the room's quorum to put its key to, pointing back at the + * suggestion it came from, and the screen hands over to the signing session it + * opened. Nothing about the list has changed by the time this screen closes. + * The form is driven by a schema this screen has never seen, so a value the + * schema refuses is named under its own input rather than found out after a + * quorum has signed. + */ +@OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) +@Composable +fun AcceptCuratedSuggestionScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String, + suggestionId: String, + initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState = + AcceptCuratedSuggestionUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateBack: () -> Unit, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val acceptCuratedSuggestionViewModel: AcceptCuratedSuggestionViewModel = viewModel( + factory = AcceptCuratedSuggestionViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + suggestionId = suggestionId, + initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + LaunchedEffect(true) { + if (initialAcceptCuratedSuggestionUIState == AcceptCuratedSuggestionUIState.Loading) { + acceptCuratedSuggestionViewModel.initiate() + } + } + + // Read out here rather than in a click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_entry) + + ScreenStateTransition(acceptCuratedSuggestionViewModel.acceptCuratedSuggestionUIState) { uiState -> + when (val state = uiState) { + is AcceptCuratedSuggestionUIState.Error -> { + // Nothing to retry: the room, the schema and the suggestion came + // from navigation arguments, and reading them again with the + // same ones fails the same way. + ErrorState(message = state.message, onRetry = null) + } + + AcceptCuratedSuggestionUIState.Loading -> { + LoadingDataIndicator() + } + + is AcceptCuratedSuggestionUIState.Loaded -> { + val canSign = state.canSign + + // The inputs come from the state rather than from whatever loaded + // it, so a screen handed a loaded state -- a preview, a layout + // test -- fills in the same as the app does. Seeding runs once. + LaunchedEffect(state) { + acceptCuratedSuggestionViewModel.seedWorkingCopy(state) + } + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { state.localChatRoom.RenderChatRoomTitleText() }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + acceptCuratedSuggestionViewModel.proposeEntry( + state = state, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the queue. The entry is not in + // the list yet -- it goes in when a + // quorum signs -- so landing on a + // queue still showing the row as + // waiting would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // The reasons are drawn on the form + // itself, under the inputs they are + // about; a snackbar would name a + // problem and scroll away from it. + onInvalid = {}, + // Stays on the form with the edits still + // in it: a failure to open a session is + // no reason to throw away the typing. + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon(Icons.Default.Draw, contentDescription = "Propose entry") + Text(stringResource(Res.string.propose_entry)) + } + } + ) + } + ) { innerPadding -> + AcceptCuratedSuggestionForm( + state = state, + viewModel = acceptCuratedSuggestionViewModel, + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) + } + } + } + } +} + +/** + * The form under the bars: what is being taken up and into what, the reasons + * it cannot be as it stands, an input per field the schema asks for, and the + * fields it keeps. + * + * A lazy list rather than a scrolling column, the way the schema editor is, + * because the number of inputs is the schema's to decide and a long form is + * still one screen. Split out so a layout test can render it without the bars. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun AcceptCuratedSuggestionForm( + state: AcceptCuratedSuggestionUIState.Loaded, + viewModel: AcceptCuratedSuggestionViewModel, + modifier: Modifier = Modifier +) { + val schema = state.schema.schema + val suggestion = state.suggestion + val editable = AcceptCuratedSuggestionViewModel.editableFields(state) + val kept = AcceptCuratedSuggestionViewModel.keptFields(state) + val entryProblems = viewModel.problemsAboutTheEntry(state) + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues( + horizontal = MaterialTheme.spacing.screenMargin, + vertical = MaterialTheme.spacing.itemGap + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + text = suggestion.entry.title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + item { + Text( + text = stringResource( + Res.string.suggested_by_on, + suggestion.suggesterName(), + suggestion.suggestedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + item { + Text( + text = stringResource(Res.string.take_this_suggestion_into_the_list, schema.displayName()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + item { + Text( + text = stringResource(Res.string.the_group_signs_an_entry_so_it_takes_a_quorum), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + // Said up front rather than left to the queue's mark: a member who did + // not notice would propose what they took for a new entry and get a + // revision, which is the right outcome but a surprising one. + if (suggestion.isCurated) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource(Res.string.the_group_already_has_an_entry_for_this), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + if (!state.canSign) { + item { + Text( + text = stringResource(Res.string.this_group_has_no_shared_key_to_sign_an_entry), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + // The problems no input can carry: about the entry as a whole, or about + // a field the form keeps rather than asks for. Named, and all at once, + // for the reason the schema editor gives -- a button that does nothing + // is indistinguishable from a missed tap. + if (entryProblems.isNotEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = stringResource(Res.string.this_entry_cannot_be_signed_as_it_is), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + + entryProblems.forEach { problem -> + val field = schema.fields.firstOrNull { it.name == problem.field } + + Text( + text = stringResource( + Res.string.field_and_problem, + field?.labelOrName() ?: problem.field, + problem.message(field, schema) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + + editable.forEach { field -> + item(key = field.name) { + val inputs = viewModel.inputs[field.name].orEmpty() + val problem = viewModel.problemsFor(field.name).firstOrNull() + + EntryFieldEditor( + field = field, + schema = schema, + inputs = inputs, + problem = problem, + onAddValue = { viewModel.addValue(field.name) }, + onRemoveValue = { index -> viewModel.removeValue(field.name, index) } + ) + } + } + + if (kept.isNotEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.kept_from_the_suggestion), + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = stringResource(Res.string.filled_in_by_the_suggester_s_app_and_kept), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + kept.forEach { field -> + item(key = "kept:${field.name}") { + KeptField(field = field, values = suggestion.entry.values[field.name].orEmpty()) + } + } + } + } +} + +/** + * The inputs for one field, in the shape its type asks for. + * + * An enum is its options as chips, one selected, because typing a word that + * has to be one of six is a way to misspell it; everything else is a text + * field with the keyboard its type wants. A field that repeats is one input + * per value with a way to drop each and a way to add one, since the + * suggestion may have carried two watch links and the list may want a third. + * The schema's hint sits under the input until a problem takes its place. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun EntryFieldEditor( + field: CuratedField, + schema: CuratedSchema, + inputs: List, + problem: CuratedEntryProblem?, + onAddValue: () -> Unit, + onRemoveValue: (index: Int) -> Unit +) { + val message = problem?.message(field, schema) + val options = field.config.options + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + if (field.type == CuratedFieldType.Enum && !options.isNullOrEmpty()) { + EnumInput(field = field, options = options, input = inputs.firstOrNull(), message = message) + } else { + inputs.forEachIndexed { index, input -> + Row(verticalAlignment = Alignment.CenterVertically) { + EntryTextField( + modifier = Modifier.weight(1f), + state = input, + field = field, + // A problem is about the field, and the field is every + // input; the first is where a reader's eye lands. + message = message.takeIf { index == 0 }, + isError = problem != null + ) + + if (field.config.repeat == true) { + IconButton(onClick = { onRemoveValue(index) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove this value", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + if (field.config.repeat == true) { + TextButton(onClick = onAddValue) { + Icon(Icons.Default.Add, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.add_another)) + } + } + } + } +} + +/** + * An enum as chips. The selection is written into the field's one input, so + * the draft reads an enum the way it reads everything else; a value the + * suggestion carried that is not among the options is shown as an unselected + * set with the problem under it, rather than quietly dropped. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun EnumInput( + field: CuratedField, + options: List, + input: TextFieldState?, + message: String? +) { + val selected = input?.text?.toString() + + Text( + text = field.labelOrName(), + style = MaterialTheme.typography.labelMedium, + color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + options.forEach { option -> + FilterChip( + selected = option == selected, + onClick = { input?.setTextAndPlaceCursorAtEnd(option) }, + label = { Text(option) } + ) + } + } + + (message ?: field.config.hint)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * One text input of the form, in the shape the app's other forms use: the + * borderless outlined field over the bottom bar's tint, labelled as the + * schema labels it and placeholdered as the schema says. + * + * The keyboard follows the type -- digits for a year, a URL keyboard for a + * link -- and a long text gets room to be one. The supporting line is the + * problem when there is one and the schema's hint when there is not. + */ +@Composable +private fun EntryTextField( + state: TextFieldState, + field: CuratedField, + message: String?, + isError: Boolean, + modifier: Modifier = Modifier +) { + val keyboardType = when (field.type) { + CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> KeyboardType.Number + CuratedFieldType.Url, CuratedFieldType.Image -> KeyboardType.Uri + else -> KeyboardType.Text + } + + OutlinedTextField( + modifier = modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), + state = state, + lineLimits = if (field.type == CuratedFieldType.LongText) { + TextFieldLineLimits.MultiLine(minHeightInLines = 3) + } else { + TextFieldLineLimits.SingleLine + }, + isError = isError, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + label = { + Text(text = field.labelOrName()) + }, + placeholder = field.placeholder.takeIf { it.isNotBlank() }?.let { { Text(text = it) } }, + supportingText = (message ?: field.config.hint)?.let { { Text(text = it) } } + ) +} + +/** + * A field the form keeps rather than asks for: the schema's label over the + * values as the suggestion carried them, the way the queue's sheet shows an + * entry. Monospaced where the value is a thing rather than prose. + */ +@Composable +private fun KeptField(field: CuratedField, values: List) { + val isMonospaced = when (field.type) { + CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true + else -> false + } + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = field.labelOrName(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = values.joinToString("\n"), + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isMonospaced) FontFamily.Monospace else null, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * One problem in words, for the input it is about. + * + * The bounds are read off the field's config, since "at most 500" says + * something and "too long" does not; a `require-any` rule names the other + * fields it could have been satisfied by, as the schema labels them. + */ +@Composable +internal fun CuratedEntryProblem.message(field: CuratedField?, schema: CuratedSchema): String = when (reason) { + CuratedEntryProblem.Reason.Missing, + CuratedEntryProblem.Reason.Empty -> stringResource(Res.string.this_field_is_required) + + CuratedEntryProblem.Reason.Repeated -> stringResource(Res.string.only_one_value_for_this_field) + CuratedEntryProblem.Reason.NotOneWord -> stringResource(Res.string.one_word_without_spaces) + CuratedEntryProblem.Reason.NotAUrl -> stringResource(Res.string.that_is_not_a_web_address) + CuratedEntryProblem.Reason.NotHttps -> stringResource(Res.string.that_must_be_an_https_address) + CuratedEntryProblem.Reason.NotAnOption -> stringResource(Res.string.pick_one_of_the_options) + CuratedEntryProblem.Reason.NotAWholeNumber -> stringResource(Res.string.a_whole_number) + CuratedEntryProblem.Reason.NotPositive -> stringResource(Res.string.more_than_zero) + + CuratedEntryProblem.Reason.BelowMinimum -> + stringResource(Res.string.at_least_n, field?.config?.min?.toString() ?: "") + + CuratedEntryProblem.Reason.AboveMaximum -> + stringResource(Res.string.at_most_n, field?.config?.max?.toString() ?: "") + + CuratedEntryProblem.Reason.TooLong -> + stringResource(Res.string.at_most_n_characters, field?.config?.max?.toString() ?: "") + + CuratedEntryProblem.Reason.PatternMismatch -> + stringResource(Res.string.must_match_the_pattern, field?.config?.pattern ?: "") + + CuratedEntryProblem.Reason.RequireAnyUnmet -> stringResource( + Res.string.at_least_one_of_these_is_needed, + schema.requireAny.firstOrNull { it.firstOrNull() == this.field } + .orEmpty() + .joinToString { name -> schema.fields.firstOrNull { it.name == name }?.labelOrName() ?: name } + ) + + // None of these can come out of the form: the builder writes the kind, the + // root and the source, and the group is the curator. Named all the same, + // since a check that cannot fail is the one that does. + CuratedEntryProblem.Reason.WrongKind, + CuratedEntryProblem.Reason.NotAReply, + CuratedEntryProblem.Reason.RepliesElsewhere, + CuratedEntryProblem.Reason.NotPermitted, + CuratedEntryProblem.Reason.NotTheCurator, + CuratedEntryProblem.Reason.SourceMalformed -> stringResource(Res.string.the_entry_cannot_be_signed_as_it_is) +} + +@ConformancePreviews +@Composable +private fun AcceptCuratedSuggestionScreenPreview() { + val chatRoomId = "d4c3b2a1".repeat(8) + val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), + CuratedField( + "type", + CuratedFieldType.Enum, + isRequired = true, + label = "Type", + config = CuratedFieldConfig(options = listOf("movie", "documentary", "short")) + ), + CuratedField( + "watchUrl", + CuratedFieldType.Url, + isRequired = false, + label = "Watch URL", + config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true) + ), + CuratedField( + "description", + CuratedFieldType.LongText, + isRequired = false, + label = "Why", + config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) + ), + CuratedField( + "hashtags", + CuratedFieldType.Token, + isRequired = false, + label = "Hashtags", + config = CuratedFieldConfig(tag = "t", repeat = true, derived = true) + ), + ), + relays = listOf("wss://relay.one.example") + ) + ) + + val suggestion = CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = "a".repeat(64), + pubKey = suggester, + createdAt = 1_700_000_900, + identifier = "the-rise-and-rise-of-bitcoin-2014", + title = "The Rise and Rise of Bitcoin", + values = mapOf( + "identifier" to listOf("the-rise-and-rise-of-bitcoin-2014"), + "title" to listOf("The Rise and Rise of Bitcoin"), + "year" to listOf("2014"), + "type" to listOf("documentary"), + "watchUrl" to listOf("http://bitcoindoc.com/"), + "description" to listOf("Follows the early Bitcoin community from 2011 onward."), + "hashtags" to listOf("bitcoin", "documentary"), + ) + ), + event = NostrEvent( + id = "a".repeat(64), + pubKey = suggester, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = emptyArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_900) + ), + suggester = null, + isCurated = false + ) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AcceptCuratedSuggestionScreen( + activeUserPublicKey = "u".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + suggestionId = suggestion.id, + initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "u".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + suggestion = suggestion, + canSign = true + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateBack = {}, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 7314fbfa..17733468 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -185,6 +185,7 @@ fun BroadcastGroupSignedEventScreen( kind = signedEvent.kind, tags = signedEvent.tags, content = signedEvent.content, + schemas = broadcastGroupSignedEventUIState.schemas, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt index b9378833..e126cc8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt @@ -14,11 +14,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -33,13 +33,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel @@ -47,10 +44,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.accept_this_suggestion import mantra.composeapp.generated.resources.ask_again import mantra.composeapp.generated.resources.asking_the_list_s_relays -import mantra.composeapp.generated.resources.copied_the_suggestion -import mantra.composeapp.generated.resources.copy_the_suggestion import mantra.composeapp.generated.resources.curated import mantra.composeapp.generated.resources.everything_anyone_has_suggested_to_this_list import mantra.composeapp.generated.resources.n_curated @@ -58,10 +54,10 @@ import mantra.composeapp.generated.resources.n_suggestions import mantra.composeapp.generated.resources.nobody_has_suggested_anything_to_this_list_yet import mantra.composeapp.generated.resources.one_suggestion import mantra.composeapp.generated.resources.read_from_relays +import mantra.composeapp.generated.resources.revise_the_list_s_entry import mantra.composeapp.generated.resources.suggested_by_on import mantra.composeapp.generated.resources.suggestions import mantra.composeapp.generated.resources.taken_up_by_the_group_into_the_list -import mantra.composeapp.generated.resources.the_signed_event import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GroupSignedEvent @@ -79,6 +75,8 @@ import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.curated.CuratedVisibility import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.AcceptCuratedSuggestionRoute +import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.EmptyState import press.mantra.compose.ui.composable.widgets.ErrorState @@ -87,7 +85,6 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.readableContent @@ -109,9 +106,9 @@ import kotlin.time.Instant * same from here. A suggestion that does not satisfy the schema is not on this * screen at all; see `CuratedSuggestion`. * - * A row opens a sheet with the whole entry, field by field, and the event it - * came as -- the thing a member can hand to somebody else to check, and the - * thing a canonical entry is made from. + * A row opens a sheet with the whole entry, field by field, and the one thing + * a member can do about it from here: take it into the list, which opens the + * form that proposes the canonical entry for the group to sign. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -125,6 +122,7 @@ fun CuratedSuggestionListScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, ) { val curatedSuggestionListViewModel: CuratedSuggestionListViewModel = viewModel( factory = CuratedSuggestionListViewModel.factory( @@ -247,6 +245,18 @@ fun CuratedSuggestionListScreen( CuratedSuggestionSheet( suggestion = suggestion, schema = state.schema.schema, + onAccept = { + openSuggestionId = null + onNavigateToRoute( + AcceptCuratedSuggestionRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + suggestionId = suggestion.id + ) + ) + }, onDismiss = { openSuggestionId = null } ) } @@ -427,23 +437,24 @@ private fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = private const val TITLE_TAG = "title" /** - * The whole of one suggestion, and a way to take it somewhere else. + * The whole of one suggestion, and the way to take it into the list. * * Every field the entry has a value for, labelled as the schema labels it, so - * the sheet reads as the form would have -- and then the event itself, which - * is the only part that can be checked, and the part a canonical entry is - * built from. Byte for byte, the way the key state sheet shows the group's own - * event, and for the same reason: a prettier rendering is a different string - * to the one whose id was hashed. + * the sheet reads as the form would have -- and then the button, which is the + * one thing a member can do about a suggestion: open the form that proposes it + * as the list's own, for the group to sign. The event itself is not shown; it + * is what the form is built from, and the form shows it as fields rather than + * as JSON, which is how a member reads it and how the group will sign it. */ @Composable private fun CuratedSuggestionSheet( suggestion: CuratedSuggestion, schema: CuratedSchema, + onAccept: () -> Unit, onDismiss: () -> Unit ) { ModalBottomSheet(onDismiss = onDismiss) { - CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema) + CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema, onAccept = onAccept) } } @@ -455,15 +466,9 @@ private fun CuratedSuggestionSheet( @Composable internal fun CuratedSuggestionSheetContent( suggestion: CuratedSuggestion, - schema: CuratedSchema + schema: CuratedSchema, + onAccept: () -> Unit ) { - val clipboardManager = LocalClipboardManager.current - - // Read out here rather than in the click handler: both of these are - // composable, and an onClick lambda is not. - val notify = rememberNotifier(rememberCoroutineScope()) - val copied = stringResource(Res.string.copied_the_suggestion) - val entry = suggestion.entry Column(modifier = Modifier.fillMaxWidth()) { @@ -514,44 +519,27 @@ internal fun CuratedSuggestionSheetContent( } } - Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) - - Text( - text = stringResource(Res.string.the_signed_event), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) - - val json = suggestion.event.toEvent().toJson() - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small - ) { - Text( - text = json, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(MaterialTheme.spacing.containerPadding) - ) - } - Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) - FilledTonalButton( - onClick = { - clipboardManager.setText(AnnotatedString(json)) - notify(copied) - }, + // The same button either way, worded for what it will do: a suggestion + // the group already took up is revised in place, since the canonical + // entry keeps its identifier, and a member should not have to find that + // out from the queue afterwards. + Button( + onClick = onAccept, modifier = Modifier.fillMaxWidth() ) { - Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Icon(Icons.Default.Draw, contentDescription = Decorative) Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) - Text(text = stringResource(Res.string.copy_the_suggestion)) + Text( + text = stringResource( + if (suggestion.isCurated) { + Res.string.revise_the_list_s_entry + } else { + Res.string.accept_this_suggestion + } + ) + ) } } } @@ -683,7 +671,8 @@ private fun CuratedSuggestionListScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateBack = {} + onNavigateBack = {}, + onNavigateToRoute = {} ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 62dc90ad..5cdb1334 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -42,6 +42,7 @@ import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen import press.mantra.compose.ui.composable.AddGroupPostScreen +import press.mantra.compose.ui.composable.AcceptCuratedSuggestionScreen import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen import press.mantra.compose.ui.composable.CuratedSuggestionListScreen @@ -133,6 +134,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute +import press.mantra.compose.ui.composable.navigation.routes.AcceptCuratedSuggestionRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute @@ -1128,6 +1130,35 @@ fun MantraNavHost( chatRepository = databaseChatRepository, onNavigateBack = { navController.popBackStack() + }, + onNavigateToRoute = { acceptRoute -> + navController.navigate(route = acceptRoute) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AcceptCuratedSuggestionScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + identifier = route.identifier, + suggestionId = route.suggestionId, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the form so back returns to the queue rather + // than to an entry whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt new file mode 100644 index 00000000..2ade6774 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt @@ -0,0 +1,22 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The form for taking one suggestion into a list the group curates: the entry + * as it was suggested, open for the group's edits, and a button that asks the + * group to sign it as the list's own. + * + * [identifier] is the schema's `d`, for the reason `CuratedSuggestionListRoute` + * gives; [suggestionId] is the suggestion's event id rather than its + * coordinate, because the canonical entry pins the exact version the group + * read -- the NIP's `e` tag -- and the id is what names a version. + */ +@Serializable +data class AcceptCuratedSuggestionRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String?, + val identifier: String, + val suggestionId: String +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt new file mode 100644 index 00000000..d172ec2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt @@ -0,0 +1,324 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.curated.CuratedEntryProblem +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedSuggestionRef +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState + +/** + * One suggestion, taken into the list: the entry as somebody suggested it, + * edited by a member, and proposed as the group's own. + * + * Nothing here is saved. The working copy is one input per value the entry + * has, held on this view model rather than in the composition so that the + * window turning does not throw an edit away, and the button folds it into a + * kind 31890 for the room's quorum to put its key to. The list changes on + * every member's device at once, when a quorum has signed, or not at all. + * + * ### What is edited, and what is kept + * + * Curation is editorial -- the NIP says the curator may fix a year or swap a + * poster on the way through -- so every field the schema's form asks for is + * open. Two things are not. The `d` is kept whatever field writes it, because + * the canonical entry SHOULD land on the suggestion's coordinate so that + * accepting it twice revises one entry rather than making two. And a field the + * schema marks `derived` is kept as the suggester's client filled it in: this + * app derives nothing, and an entry a form never asked for is not one a form + * should let a member invent. + * + * ### Refused before proposed + * + * The button runs the queue's own verifier over the event it is about to + * propose, and refuses with the fields named. A signature is the most expensive + * thing this app does, and a canonical entry the queue would not count -- a + * year outside the schema's range, an option that is not one -- would be a + * quorum spent on an entry no client shows. + */ +class AcceptCuratedSuggestionViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + /** The `d` of the schema whose list the entry goes into. */ + val identifier: String, + /** The event id of the suggestion being taken up. */ + val suggestionId: String, + initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var acceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState by mutableStateOf( + initialAcceptCuratedSuggestionUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * The working copy: for each field the form asks for, one input per value. + * + * A field that repeats has as many inputs as the suggestion had values, and + * can be given more; every other field has exactly one. Keyed by the + * schema's field name, which is what the entry is keyed by too. + */ + val inputs: SnapshotStateMap> = mutableStateMapOf() + + /** + * Why the last proposal did not go out, or nothing. Set by the button and + * left showing until the next press, since the button is the thing that + * will clear it. + */ + var problems: List by mutableStateOf(emptyList()) + private set + + private var isSeeded = false + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find the group") + return@launch + } + + val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier } + if (schema == null) { + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that schema") + return@launch + } + + // The same reading the queue screen makes, so what is accepted here + // is what was shown there: newest per coordinate, checked against + // the schema, and marked when the group already has an entry for it. + // An id the reading no longer holds is a version something newer has + // replaced since the row was tapped, and the newer one is what the + // queue now shows. + val events = nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()).first() + val suggestion = CuratedSuggestion.queueOf(schema, events).firstOrNull { it.id == suggestionId } + if (suggestion == null) { + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that suggestion") + return@launch + } + + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema, + suggestion = suggestion, + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + + /** + * Fills the working copy from the suggestion, once. + * + * One input per value the suggestion carries for each field the form asks + * for, and one blank input for a field it carries nothing for, so there is + * somewhere to type. Called from the screen rather than from [initiate], + * because the loader is not the only way a loaded state arrives: a preview + * and a layout test both hand one straight in. Runs once, so the effect + * that calls it re-firing does not undo an edit. + */ + fun seedWorkingCopy(state: AcceptCuratedSuggestionUIState.Loaded) { + if (isSeeded) return + isSeeded = true + + val entry = state.suggestion.entry + + editableFields(state).forEach { field -> + val values = entry.values[field.name].orEmpty().ifEmpty { listOf("") } + inputs[field.name] = mutableStateListOf(*values.map { TextFieldState(it) }.toTypedArray()) + } + } + + /** Adds a blank input under [fieldName], for a field that repeats. */ + fun addValue(fieldName: String) { + inputs[fieldName]?.add(TextFieldState()) + } + + /** + * Drops the input at [index] under [fieldName]. The last input stays, blank + * rather than gone, so the field is still there to be typed into. + */ + fun removeValue(fieldName: String, index: Int) { + val values = inputs[fieldName] ?: return + if (index !in values.indices) return + + if (values.size == 1) { + values[0] = TextFieldState() + } else { + values.removeAt(index) + } + } + + /** + * The problems about [fieldName], for the field's own error state. A + * `require-any` rule is filed under its first field, so it shows there. + */ + fun problemsFor(fieldName: String): List = problems.filter { it.field == fieldName } + + /** + * The problems that are about no field on the form: the entry as a whole, + * or a field the form keeps rather than asks for. Shown at the top, since + * there is no input to show them under. + */ + fun problemsAboutTheEntry(state: AcceptCuratedSuggestionUIState.Loaded): List { + val editable = editableFields(state).map { it.name }.toSet() + return problems.filterNot { it.field in editable } + } + + /** + * The entry as the form has it: what was typed for every field the form + * asks for, and what was suggested for every field it keeps. + * + * Keyed by field name and holding every value, which is the shape + * `CuratedEntryEvent.canonicalTemplate` lays out as tags. Blank inputs come + * through and are dropped there; a blank is not a value. + */ + fun draft(state: AcceptCuratedSuggestionUIState.Loaded): Map> { + val suggested = state.suggestion.entry.values + + return state.schema.schema.fields.associate { field -> + field.name to (inputs[field.name]?.map { it.text.toString() } ?: suggested[field.name].orEmpty()) + } + } + + /** + * Opens a session over the entry, or says why not. + * + * Two refusals before anything is proposed and each is the caller's to + * say: an entry the queue would refuse, which is [onInvalid] with the + * reasons left in [problems]; and a proposal that could not be opened, + * which is [onFailure]. Both stay on the form with the edits still in it. + */ + fun proposeEntry( + state: AcceptCuratedSuggestionUIState.Loaded, + onSuccess: (sessionId: String) -> Unit, + onInvalid: () -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. Two sessions over two versions of one + // entry would leave the list decided by whichever quorum finished last. + if (isActionPending.value) return + + val template = state.schema.canonicalTemplate( + values = draft(state), + source = CuratedSuggestionRef.of(state.suggestion.entry), + ) + + problems = state.schema.verifyCanonical(template) + if (problems.isNotEmpty()) { + onInvalid.invoke() + return + } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = state.localChatRoom, + userPublicKey = activeUserPublicKey, + kind = template.kind, + tags = template.tags, + content = template.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a canonical entry for ${state.schema.coordinate()}", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AcceptCuratedSuggestionViewModel" + + /** The tag of the field the form keeps whatever else it says: the entry's coordinate. */ + private const val IDENTIFIER_TAG = "d" + + /** + * The fields the form opens for editing: the ones the schema's form asks + * for, less the one writing to `d`. See the class note for why those two + * are kept as they were suggested. + */ + fun editableFields(state: AcceptCuratedSuggestionUIState.Loaded): List = + state.schema.schema.formFields().filterNot { it.tag() == IDENTIFIER_TAG } + + /** + * The fields the form shows but does not open: what the suggester's client + * filled in, and what the entry is addressed by. In the schema's order, + * and only the ones the suggestion has a value for, since a kept field + * with nothing in it has nothing to show. + */ + fun keptFields(state: AcceptCuratedSuggestionUIState.Loaded): List { + val editable = editableFields(state).map { it.name }.toSet() + val values = state.suggestion.entry.values + + return state.schema.schema.fields.filter { it.name !in editable && values[it.name].orEmpty().isNotEmpty() } + } + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String, + suggestionId: String, + initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState = + AcceptCuratedSuggestionUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AcceptCuratedSuggestionViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + suggestionId = suggestionId, + initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt index 383a0ceb..1f811e9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt @@ -32,9 +32,11 @@ import press.mantra.compose.network.relays.EventPublishTransport import press.mantra.compose.network.relays.NostrPublishResult import press.mantra.compose.network.relays.RelayPool import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState @@ -67,9 +69,9 @@ import kotlin.time.Duration.Companion.milliseconds * * [defaultRelaysFor]: the group's own General list where it has agreed one, the * app's publish set where it has not, plus the indexers for a relay list and the - * list's own relays for a schema, minus anything the group has blocked. The - * member can add and remove before sending, and the seeded list is a suggestion - * rather than a rule. + * list's own relays for a schema and for an entry into it, minus anything the + * group has blocked. The member can add and remove before sending, and the + * seeded list is a suggestion rather than a rule. */ class BroadcastGroupSignedEventViewModel( val chatRoomId: String, @@ -176,6 +178,7 @@ class BroadcastGroupSignedEventViewModel( localChatRoom = localChatRoom, signedEvent = signedEvent, relayLists = chatRepository.groupRelayLists(chatRoomId), + schemas = chatRepository.groupCuratedSchemas(chatRoomId), ) } } @@ -189,11 +192,17 @@ class BroadcastGroupSignedEventViewModel( * loaded state straight in. Once and only once, so a member who emptied the * list on purpose does not find it refilled by a state the view model re-emits. */ - fun seedRelays(relayLists: List, kind: Kind, tags: Array>, content: String) { + fun seedRelays( + relayLists: List, + kind: Kind, + tags: Array>, + content: String, + schemas: List = emptyList(), + ) { if (isSeeded) return isSeeded = true - relays.addAll(defaultRelaysFor(relayLists, kind, tags, content)) + relays.addAll(defaultRelaysFor(relayLists, kind, tags, content, schemas)) } fun clearAddFailure() { @@ -377,6 +386,12 @@ class BroadcastGroupSignedEventViewModel( * - A curated schema goes to the relays it names for its own replies, so a * client reading suggestions there finds what they are replying to. * + * - An entry into a curated list goes to the same relays as its schema, + * which the NIP makes a MUST: that is where the list's readers look, + * and where the queue screen reads the entry back to mark the + * suggestion it came from. The entry names its schema by coordinate, + * so the relays are read off the group's own copy of it in [schemas]. + * * And nothing goes to a relay the group has blocked, whatever the other * lists say; that is what a blocked list is for. */ @@ -385,6 +400,7 @@ class BroadcastGroupSignedEventViewModel( kind: Kind, tags: Array> = emptyArray(), content: String = "", + schemas: List = emptyList(), ): List { val general = relayLists.firstOrNull { it.set == GroupRelaySet.General } ?.relays.orEmpty() @@ -399,12 +415,18 @@ class BroadcastGroupSignedEventViewModel( emptyList() } - val schemaRelays = if (kind == CuratedSchemaEvent.KIND) { - runCatching { CuratedSchemaEvent.read(tags, content).relays } - .getOrDefault(emptyList()) - .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } - } else { - emptyList() + val schemaRelays = when (kind) { + CuratedSchemaEvent.KIND -> + runCatching { CuratedSchemaEvent.read(tags, content).relays } + .getOrDefault(emptyList()) + .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } + + CuratedEntryEvent.CANONICAL_KIND -> + schemas.firstOrNull { it.isRepliedToBy(tags) } + ?.schema?.relays.orEmpty() + .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } + + else -> emptyList() } val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt new file mode 100644 index 00000000..381447b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt @@ -0,0 +1,34 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema + +sealed interface AcceptCuratedSuggestionUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** The list the entry would go into, as the group last signed it. */ + val schema: GroupCuratedSchema, + /** + * The suggestion being taken up, as the queue screen read it: the entry + * keyed by the schema's fields, who suggested it, and whether the group + * already has. The form starts as this, and the canonical entry points + * back at it. + */ + val suggestion: CuratedSuggestion, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the form writable and the button inert, for the reason + * the other editors give: reading and editing cost nothing, and only a + * share-holder can open the session that would sign. + */ + val canSign: Boolean = false, + ): AcceptCuratedSuggestionUIState + + data class Error( + val message: String + ): AcceptCuratedSuggestionUIState + + data object Loading: AcceptCuratedSuggestionUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt index 2f7437d1..277d2c9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt @@ -2,6 +2,7 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupRelayList sealed interface BroadcastGroupSignedEventUIState { @@ -16,6 +17,12 @@ sealed interface BroadcastGroupSignedEventUIState { * `BroadcastGroupSignedEventViewModel.defaultRelaysFor`. */ val relayLists: List = emptyList(), + /** + * The lists the group curates, for an entry into one of them: the NIP + * says a canonical entry goes to its schema's relays, and the entry + * names its schema by coordinate rather than carrying the relays itself. + */ + val schemas: List = emptyList(), ): BroadcastGroupSignedEventUIState data class Error( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt index 8a9ca840..1949b9ab 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt @@ -3,6 +3,7 @@ package press.mantra.compose.nostr.curated import com.vitorpamplona.quartz.nip01Core.core.Event import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -21,6 +22,12 @@ import kotlin.test.assertTrue * schema's field names, with the tags the schema does not define ignored rather * than kept. `director`, `i` and `lang` are on the NIP's example and on no * field of the NIP's schema, and they are not in the reading. + * + * And the write side is pinned against the same example from the other end: + * the canonical entry built from the suggestion's values is the NIP's canonical + * entry, tag for tag, and what the builder writes is what the reader reads + * back -- which is the property that lets the accept form and the queue screen + * agree about an entry without either seeing the other's code. */ class CuratedEntryEventTest { private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25" @@ -341,6 +348,163 @@ class CuratedEntryEventTest { assertNull(CuratedCoordinate.parse("naddr1qqxnzd3cxqmrzv3exgmr2wfeqgs"), "a bech32 address is not a coordinate") } + // -- the write side ----------------------------------------------------------- + + /** The NIP's example, less the tags its schema does not define, which the builder cannot know to write. */ + private val riseAndRiseAsTheSchemaKnowsIt: List> = riseAndRise + .filterNot { it[0] == "director" || it[0] == "i" || it[0] == "lang" } + .map { it.toList() } + + private val sourceBlock: List> = listOf( + listOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), + listOf("e", suggestionId, "", "mention"), + listOf("p", suggester), + ) + + @Test + fun `the canonical entry built from the NIP's suggestion is the NIP's canonical entry`() { + val suggested = CuratedEntryEvent.read(suggestion(), schema) + + val template = CuratedEntryEvent.canonicalTemplate( + schema = schema, + coordinate = coordinate, + curator = curator, + values = suggested.values, + source = CuratedSuggestionRef.of(suggested), + createdAt = 1735689720, + ) + + assertEquals(CuratedEntryEvent.CANONICAL_KIND, template.kind) + assertEquals(review, template.content) + // The first two blocks identical to the suggestion's, the third the + // pointer back: the doc's own description of the example. + assertEquals(riseAndRiseAsTheSchemaKnowsIt + sourceBlock, template.tags.map { it.toList() }) + + val preview = CuratedEntryEvent.preview(template, curator) + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator)) + + // And it reads back as what went in, which is the property the form + // and the queue rely on: the values, the `d`, and where it came from. + val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator)) + assertEquals(suggested.values, entry.values) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals(CuratedSuggestionRef(suggestionId, "31888:$suggester:imdb:tt2821314", suggester), entry.source) + } + + @Test + fun `an edit on the way through is what is signed, and the d is what was suggested`() { + val suggested = CuratedEntryEvent.read(suggestion(), schema) + val corrected = suggested.values + mapOf( + "year" to listOf("2015"), + "image" to listOf("https://example.com/a-better-poster.jpg"), + ) + + val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, corrected, CuratedSuggestionRef.of(suggested)) + val preview = CuratedEntryEvent.preview(template, curator) + + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator)) + + val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator)) + assertEquals("2015", entry.value("year")) + assertEquals("https://example.com/a-better-poster.jpg", entry.value("image")) + // Same coordinate as before the edit, so re-curating revises rather than adds. + assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate()) + } + + @Test + fun `a value the schema refuses is refused with its field named, not clipped`() { + val suggested = CuratedEntryEvent.read(suggestion(), schema) + val wrong = suggested.values + mapOf( + "year" to listOf("twenty fourteen"), + "title" to listOf("x".repeat(201)), + "type" to listOf("musical"), + ) + + val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, wrong) + val problems = CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) + + assertEquals( + setOf( + CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong), + CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber), + CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption), + ), + problems.toSet() + ) + + // The reference clips a long title to its cap; this does not, because + // the form shows the problem and the member decides how to shorten it. + assertEquals("x".repeat(201), template.tags.first { it[0] == "title" }[1]) + } + + @Test + fun `a blank is not a value, a field that repeats takes all of them, and one that does not takes the first`() { + val template = CuratedEntryEvent.canonicalTemplate( + schema = schema, + coordinate = coordinate, + curator = curator, + values = mapOf( + "identifier" to listOf("imdb:tt2821314"), + "title" to listOf(" The Rise and Rise of Bitcoin ", "A second title nobody asked for"), + "type" to listOf("documentary"), + "year" to listOf(" "), + "watchUrl" to listOf("http://bitcoindoc.com/", "", "https://mirror.example/watch"), + "description" to listOf(""), + ), + ) + + val tags = template.tags.map { it.toList() } + + assertEquals(listOf(listOf("title", "The Rise and Rise of Bitcoin")), tags.filter { it[0] == "title" }) + assertEquals(emptyList(), tags.filter { it[0] == "year" }) + assertEquals( + listOf( + listOf("r", "http://bitcoindoc.com/", "watch"), + listOf("r", "https://mirror.example/watch", "watch"), + ), + tags.filter { it[0] == "r" } + ) + assertEquals("", template.content) + assertEquals( + emptyList(), + CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) + ) + } + + @Test + fun `an entry nobody suggested has no source block, and the root carries the schema's first relay`() { + val values = CuratedEntryEvent.read(suggestion(), schema).values + + val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, values, source = null) + val tags = template.tags.map { it.toList() } + + assertEquals(listOf(listOf("a", coordinate, "wss://ephemeral.mantra.press", "root")), tags.filter { it[0] == "a" }) + assertEquals(listOf(listOf("p", curator)), tags.filter { it[0] == "p" }) + assertEquals(emptyList(), tags.filter { it[0] == "e" }) + + val entry = assertNotNull( + CuratedEntryEvent.canonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) + ) + assertNull(entry.source) + } + + @Test + fun `only a suggestion can be pointed at`() { + val theListsOwn = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator)) + + assertFailsWith { CuratedSuggestionRef.of(theListsOwn) } + } + + @Test + fun `the headline needs no schema`() { + val entry = CuratedEntryEvent.headline(canonical()) + + assertEquals("The Rise and Rise of Bitcoin", entry.title) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals(suggester, entry.source?.pubKey) + assertEquals(emptyMap(), entry.values) + } + private fun suggestion( tags: Array> = riseAndRise, content: String = review, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 2a699980..2de50968 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -1,5 +1,6 @@ package press.mantra.compose.text +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -80,6 +81,39 @@ class ProposedEventTest { assertEquals("Films worth translating · closed · 2 fields", summary.detail) } + @Test + fun `a curated entry is said by its title and who suggested it`() { + val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + val summary = ProposedEvent.summarize( + event( + CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf( + arrayOf("d", "imdb:tt2821314"), + arrayOf("title", "The Rise and Rise of Bitcoin"), + arrayOf("year", "2014"), + arrayOf("a", "31889:${"7".repeat(64)}:bitcoin.mov", "", "root"), + arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), + arrayOf("p", suggester), + ), + content = "Follows the early Bitcoin community." + ) + ) + + assertEquals("Curated entry", summary.label) + assertEquals("The Rise and Rise of Bitcoin · suggested by eebb74ab", summary.detail) + + // An entry the group added itself has nobody to credit. + val unprompted = ProposedEvent.summarize( + event( + CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf(arrayOf("d", "magic-money"), arrayOf("title", "Magic Money")), + content = "" + ) + ) + assertEquals("Magic Money", unprompted.detail) + } + @Test fun `a kind with no words of its own keeps its number`() { // Refusing to describe an event is better than describing it wrongly. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt new file mode 100644 index 00000000..860aef0e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt @@ -0,0 +1,216 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState +import kotlin.test.Test +import kotlin.time.Instant + +/** + * What the accept form puts on the screen, for a schema it has never seen. + * + * Three things worth pinning. **The inputs are the schema's**: one per field + * the form asks for, labelled as the schema labels them and seeded with what + * was suggested, with an enum as its options rather than a box to misspell + * one in. **What is kept is shown, not hidden**: the identifier and the + * derived fields are on the screen under their own heading, so what the + * member sees is what the group will sign. And **a refusal is drawn under the + * input it is about**, since the button doing nothing is indistinguishable + * from a missed tap. + */ +@OptIn(ExperimentalTestApi::class) +class AcceptCuratedSuggestionScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + domain = "films.example", + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))), + CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)), + CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)), + CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG, hint = "Why the group should watch it")), + ), + relays = listOf("wss://relay.one.example") + ).normalized() + ) + + private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf( + schema, + listOf( + LocalNostrEvent( + nostrEvent = NostrEvent( + id = "1".repeat(64), + pubKey = bob, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("year", "2018"), + arrayOf("type", "documentary"), + arrayOf("r", "https://example.com/watch", "watch"), + arrayOf("t", "bitcoin"), + arrayOf("a", schema.coordinate(), "", "root"), + ), + content = "Follows the early community.", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + profile = null + ) + ) + ).single() + + private fun state(canSign: Boolean = true, isCurated: Boolean = false) = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + suggestion = suggestion.copy(isCurated = isCurated), + canSign = canSign + ) + + @Test + fun `the inputs are the schema's, seeded from the suggestion, and what is kept is shown`() = render(state()) { + // Twice: the heading, and the title input seeded with it. + onAllNodesWithText("Magic Money").assertCountEquals(2) + onNodeWithText("Suggested by ${bob.take(8)} on ", substring = true).assertIsDisplayed() + onNodeWithText("Take this suggestion into films.example", substring = true).assertIsDisplayed() + + // The schema's labels over its values: a text field, a number, and the + // enum as chips with the suggested one among them. + onNodeWithText("Year").assertIsDisplayed() + onNodeWithText("2018").assertIsDisplayed() + onNodeWithText("Type").assertIsDisplayed() + onNodeWithText("movie").assertIsDisplayed() + onNodeWithText("documentary").assertIsDisplayed() + onNodeWithText("https://example.com/watch").assertIsDisplayed() + onNodeWithText("Follows the early community.").assertIsDisplayed() + // The schema's hint under the input it is for. + onNodeWithText("Why the group should watch it").assertIsDisplayed() + // A field that repeats can be given another value. + onNodeWithText("Add another").assertIsDisplayed() + + // Kept, and said to be: the identifier and the derived hashtags. + onNodeWithText("Kept from the suggestion").assertIsDisplayed() + onNodeWithText("Identifier").assertIsDisplayed() + onNodeWithText("magic-money").assertIsDisplayed() + onNodeWithText("Hashtags").assertIsDisplayed() + onNodeWithText("bitcoin").assertIsDisplayed() + + onNodeWithText("Propose entry").assertIsDisplayed() + onAllNodesWithText("The group already has an entry for this", substring = true).assertCountEquals(0) + } + + @Test + fun `a refusal is drawn under the input it is about`() = render(state()) { + // The suggested year is replaced with one the schema's range refuses, + // by way of the field: the test types where a member would. + onNodeWithText("2018").performTextReplacement("1850") + onNodeWithText("Propose entry").performClick() + + onNodeWithText("At least 1900.").assertIsDisplayed() + } + + @Test + fun `a device without a share sees why the button is inert`() = render(state(canSign = false)) { + onNodeWithText("This group has no shared key, so it cannot sign an entry", substring = true).assertIsDisplayed() + onNodeWithText("Propose entry").assertIsNotEnabled() + } + + @Test + fun `a suggestion the group already took up says so before the button`() = render(state(isCurated = true)) { + onNodeWithText("The group already has an entry for this", substring = true).assertIsDisplayed() + } + + /** + * The screen at a phone's width and a window tall enough to compose the + * whole of it. The state is passed in, so the NO_OP repositories are never + * asked for anything: `initiate` only runs for a screen that arrives loading. + */ + private fun render( + uiState: AcceptCuratedSuggestionUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2400) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + AcceptCuratedSuggestionScreen( + activeUserPublicKey = ada, + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + suggestionId = suggestion.id, + initialAcceptCuratedSuggestionUIState = uiState, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateBack = {}, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt index 62deff65..db5ba06c 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GroupSignedEvent @@ -184,21 +185,23 @@ class CuratedSuggestionListScreenJvmTest { } @Test - fun `the sheet has every field the entry answered, the link included, and the event`() = + fun `the sheet has every field the entry answered, the link included, and the way in`() = runDesktopComposeUiTest(width = 400, height = 2000) { + var accepted = false + setContent { MantraTheme { ProvideSnackbarHost { CuratedSuggestionSheetContent( - suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true), - schema = schema.schema + suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = false), + schema = schema.schema, + onAccept = { accepted = true } ) } } } onNodeWithText("Magic Money").assertIsDisplayed() - onNodeWithText("Taken up by the group into the list.").assertIsDisplayed() // As the schema labels them, in the schema's order. onNodeWithText("Year").assertIsDisplayed() @@ -208,9 +211,32 @@ class CuratedSuggestionListScreenJvmTest { onNodeWithText("Review").assertIsDisplayed() onNodeWithText("Follows the early community.").assertIsDisplayed() - // The event, whole, and a way to take it somewhere. - onNodeWithText("\"kind\":31888", substring = true).assertIsDisplayed() - onNodeWithText("Copy the suggestion").assertIsDisplayed() + // The one thing to do with it, and not the event: the form shows + // the entry as fields, which is how the group will sign it. + onAllNodesWithText("\"kind\":31888", substring = true).assertCountEquals(0) + onAllNodesWithText("Copy the suggestion").assertCountEquals(0) + onNodeWithText("Accept this suggestion").assertIsDisplayed().performClick() + assertTrue(accepted, "the button did not open the form") + } + + @Test + fun `a suggestion the group already took up offers a revision, not a second acceptance`() = + runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CuratedSuggestionSheetContent( + suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true), + schema = schema.schema, + onAccept = {} + ) + } + } + } + + onNodeWithText("Taken up by the group into the list.").assertIsDisplayed() + onNodeWithText("Revise the list's entry").assertIsDisplayed() + onAllNodesWithText("Accept this suggestion").assertCountEquals(0) } private fun suggestion( @@ -294,7 +320,8 @@ class CuratedSuggestionListScreenJvmTest { initialCuratedSuggestionListUIState = uiState, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateBack = {} + onNavigateBack = {}, + onNavigateToRoute = {} ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt new file mode 100644 index 00000000..97f4d2cc --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt @@ -0,0 +1,412 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedEntryProblem +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The decisions the accept form makes that nothing else can check. + * + * **What is opened and what is kept.** The form asks for what the schema's + * form asks for, and keeps the `d` and the derived fields as they were + * suggested; getting that wrong in one direction lets a member invent a + * hashtag the app was supposed to derive, and in the other loses the + * coordinate that makes a re-curation a revision. + * + * **What the button refuses.** An entry the queue would not count is refused + * with the field named, before a quorum is asked for it -- and the entry it + * does propose is the one the queue screen will read back, source and all. + * + * **What the loader finds.** The suggestion is looked up by the queue's own + * reading, so what is accepted is what was shown. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AcceptCuratedSuggestionViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))), + CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)), + CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, label = "IMDb URL", config = CuratedFieldConfig(tag = "r", marker = "imdb")), + CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)), + CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)), + ), + requireAny = listOf(listOf("watchUrl", "imdbUrl")), + relays = listOf("wss://relay.one.example"), + ).normalized(), + ) + + private val localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ) + + /** *Magic Money* as Bob suggested it: two watch links, and hashtags his client derived. */ + private val suggestedEvent = NostrEvent( + id = "1".repeat(64), + pubKey = bob, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("year", "2018"), + arrayOf("type", "documentary"), + arrayOf("r", "https://example.com/watch", "watch"), + arrayOf("r", "https://mirror.example/watch", "watch"), + arrayOf("t", "bitcoin"), + arrayOf("t", "documentary"), + arrayOf("a", schema.coordinate(), "", "root"), + ), + content = "Follows the early community.", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf( + schema, + listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null)) + ).single() + + private fun loaded(canSign: Boolean = true) = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema, + suggestion = suggestion, + canSign = canSign, + ) + + /** What was asked of the signing repository, and the session it answers with. */ + private class RecordingSigning(private val session: FrostSigningSession?) : + FrostSigningRepository by FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY { + var kind: Kind? = null + var tags: Array>? = null + var content: String? = null + + override suspend fun canSign(chatRoomId: String): Boolean = true + + override suspend fun proposeSigning( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + kind: Kind, + tags: Array>, + content: String + ): FrostSigningSession? { + this.kind = kind + this.tags = tags + this.content = content + return session + } + } + + private fun session() = FrostSigningSession( + id = "session-1", + chatRoomId = chatRoomId, + coordinatorPublicKey = ada, + userPublicKey = ada, + dkgSessionId = "dkg-1", + threshold = 2, + participantCount = 3, + signerId = 1, + ) + + private fun viewModel( + signing: FrostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository: ChatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + suggestionId: String = suggestion.id, + ) = AcceptCuratedSuggestionViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = ada, + relayHint = null, + identifier = "films", + suggestionId = suggestionId, + initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loading, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = signing, + ) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the form opens what the schema asks for, one input per value, and keeps the rest`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + // Not the identifier -- it writes to `d` -- and not the derived hashtags. + assertEquals( + listOf("title", "year", "type", "watchUrl", "imdbUrl", "review"), + AcceptCuratedSuggestionViewModel.editableFields(state).map { it.name } + ) + assertEquals( + listOf("identifier", "hashtags"), + AcceptCuratedSuggestionViewModel.keptFields(state).map { it.name } + ) + + assertEquals(listOf("Magic Money"), viewModel.inputs["title"]?.map { it.text.toString() }) + assertEquals(listOf("2018"), viewModel.inputs["year"]?.map { it.text.toString() }) + // A field that repeats gets an input per value the suggestion carried. + assertEquals( + listOf("https://example.com/watch", "https://mirror.example/watch"), + viewModel.inputs["watchUrl"]?.map { it.text.toString() } + ) + // A field the suggestion has nothing for gets one blank to type into. + assertEquals(listOf(""), viewModel.inputs["imdbUrl"]?.map { it.text.toString() }) + assertNull(viewModel.inputs["identifier"], "the identifier was opened for editing") + assertNull(viewModel.inputs["hashtags"], "a derived field was opened for editing") + } + + @Test + fun `seeding again leaves an edit alone`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") + + viewModel.seedWorkingCopy(state) + + assertEquals("2019", viewModel.inputs["year"]!![0].text.toString()) + } + + @Test + fun `the draft is what was typed plus what was kept, and the d is the suggestion's`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") + viewModel.addValue("watchUrl") + viewModel.inputs["watchUrl"]!![2].setTextAndPlaceCursorAtEnd("https://third.example/watch") + viewModel.removeValue("watchUrl", 0) + + val draft = viewModel.draft(state) + + assertEquals(listOf("magic-money"), draft["identifier"]) + assertEquals(listOf("2019"), draft["year"]) + assertEquals(listOf("https://mirror.example/watch", "https://third.example/watch"), draft["watchUrl"]) + assertEquals(listOf("bitcoin", "documentary"), draft["hashtags"]) + // A blank input comes through; the builder is what drops it. + assertEquals(listOf(""), draft["imdbUrl"]) + } + + @Test + fun `removing the last input leaves a blank one rather than nothing`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + viewModel.removeValue("year", 0) + + assertEquals(listOf(""), viewModel.inputs["year"]?.map { it.text.toString() }) + } + + @Test + fun `an entry the queue would refuse is refused with the field named, and nothing is proposed`() { + val state = loaded() + val signing = RecordingSigning(session()) + val viewModel = viewModel(signing) + viewModel.seedWorkingCopy(state) + + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("1850") + viewModel.inputs["type"]!![0].setTextAndPlaceCursorAtEnd("musical") + + var invalid = false + viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { invalid = true }, onFailure = { error("failed") }) + + assertTrue(invalid) + assertEquals(listOf(CuratedEntryProblem.Reason.BelowMinimum), viewModel.problemsFor("year").map { it.reason }) + assertEquals(listOf(CuratedEntryProblem.Reason.NotAnOption), viewModel.problemsFor("type").map { it.reason }) + assertEquals(emptyList(), viewModel.problemsAboutTheEntry(state)) + assertNull(signing.kind, "a refused entry was proposed anyway") + assertFalse(viewModel.isActionPending.value) + } + + @Test + fun `a require-any rule the edit broke is filed under its first field`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + viewModel.removeValue("watchUrl", 0) + viewModel.removeValue("watchUrl", 0) + + viewModel.proposeEntry(state, onSuccess = {}, onInvalid = {}, onFailure = {}) + + assertEquals(listOf(CuratedEntryProblem.Reason.RequireAnyUnmet), viewModel.problemsFor("watchUrl").map { it.reason }) + } + + @Test + fun `the entry proposed is the canonical one the queue will read back`() { + val state = loaded() + val signing = RecordingSigning(session()) + val viewModel = viewModel(signing) + viewModel.seedWorkingCopy(state) + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") + + var sessionId: String? = null + viewModel.proposeEntry(state, onSuccess = { sessionId = it }, onInvalid = { error("invalid") }, onFailure = { error("failed") }) + + runBlocking { + withTimeout(5_000) { + while (sessionId == null) kotlinx.coroutines.delay(10) + } + } + + assertEquals("session-1", sessionId) + assertEquals(CuratedEntryEvent.CANONICAL_KIND, signing.kind) + assertEquals("Follows the early community.", signing.content) + + val tags = assertNotNull(signing.tags).map { it.toList() } + assertTrue(listOf("d", "magic-money") in tags, "the entry lost the suggestion's d") + assertTrue(listOf("year", "2019") in tags, "the edit was not signed") + assertTrue(listOf("t", "bitcoin") in tags && listOf("t", "documentary") in tags, "the derived hashtags were dropped") + assertTrue(listOf("a", schema.coordinate(), "wss://relay.one.example", "root") in tags, "the entry does not reply to the list") + assertTrue(listOf("a", "31888:$bob:magic-money", "", "mention") in tags, "the entry does not point at the suggestion") + assertTrue(listOf("e", suggestion.id, "", "mention") in tags, "the entry does not pin the version read") + assertTrue(listOf("p", bob) in tags, "the suggester is not credited") + + // And it is what the queue counts as the group's entry for that suggestion. + val signed = NostrEvent( + id = "2".repeat(64), + pubKey = chatRoomId, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = signing.tags!!, + content = signing.content!!, + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_900), + ) + val queue = CuratedSuggestion.queueOf( + schema, + listOf(LocalNostrEvent(suggestedEvent, null), LocalNostrEvent(signed, null)) + ) + assertTrue(queue.single().isCurated, "the queue does not count the proposed entry as curating the suggestion") + } + + @Test + fun `a session that could not be opened is a failure, and the form stays`() { + val state = loaded() + val viewModel = viewModel(RecordingSigning(session = null)) + viewModel.seedWorkingCopy(state) + + var failed = false + viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { error("invalid") }, onFailure = { failed = true }) + + runBlocking { + withTimeout(5_000) { + while (!failed) kotlinx.coroutines.delay(10) + } + } + + assertEquals(emptyList(), viewModel.problems) + assertEquals("Magic Money", viewModel.inputs["title"]!![0].text.toString()) + } + + @Test + fun `the loader finds the suggestion by the queue's reading, and says when it cannot`() { + val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> = + flowOf(listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null))) + } + val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom + override suspend fun groupCuratedSchemas(chatRoomId: String): List = listOf(schema) + } + + val found = viewModel(RecordingSigning(null), nostrRepository, chatRepository) + found.initiate() + val loaded = await { found.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Loaded } + assertEquals("Magic Money", loaded.suggestion.entry.title) + assertTrue(loaded.canSign) + + val missing = viewModel(RecordingSigning(null), nostrRepository, chatRepository, suggestionId = "9".repeat(64)) + missing.initiate() + val error = await { missing.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Error } + assertEquals("Couldn't find that suggestion", error.message) + } + + private fun await(read: () -> T?): T = runBlocking { + withTimeout(5_000) { + var value = read() + while (value == null) { + kotlinx.coroutines.delay(10) + value = read() + } + value + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt index 64b36d13..9ef230c7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt @@ -26,7 +26,11 @@ import press.mantra.compose.nostr.GroupRelay import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState @@ -232,6 +236,52 @@ class BroadcastGroupSignedEventViewModelJvmTest { assertEquals(listOf(one, three), seeded) } + @Test + fun `an entry into a curated list goes to the list's relays, found by the coordinate it replies to`() { + val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) + + val films = GroupCuratedSchema( + signedEvent = relaySignedEvent(GroupRelaySet.General).copy(kind = CuratedSchemaEvent.KIND), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + relays = listOf(three.url), + ).normalized(), + ) + val books = films.copy(schema = films.schema.copy(identifier = "books", relays = listOf(two.url))) + + val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor( + relayLists = relayLists, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("a", films.coordinate(), "", "root"), + ), + content = "", + schemas = listOf(books, films), + ) + + // The group's relay, then the relays of the list replied to -- and not + // the other list's: the NIP sends an entry where its own schema says. + assertEquals(listOf(one, three), seeded) + + // An entry whose schema this device does not hold goes where the group + // publishes, since there is nothing else to go on. + assertEquals( + listOf(one), + BroadcastGroupSignedEventViewModel.defaultRelaysFor( + relayLists = relayLists, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf(arrayOf("a", "31889:${"0".repeat(64)}:elsewhere", "", "root")), + schemas = listOf(books, films), + ) + ) + } + @Test fun `seeding happens once, so an emptied list stays empty`() { val viewModel = viewModel() From a204279e2addaea11ec11f3d3b9f182c6a0019b4 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 15:23:33 +0200 Subject: [PATCH 24/71] feat(groups): the entries the group has accepted, under the schemas on its screen A section for the kind 31890s the group signed, read off GroupSignedEvent the way the profile, the posts, the relay lists and the schemas are. The reading gate is theirs -- the room as author, the room's real signature -- plus two of its own: an entry is matched to the list it replies to by the coordinate in its a root, and checked against that list's fields exactly as the queue checks a copy off a relay, so what this section calls accepted is what the queue will mark curated once the entry is broadcast and read back. Newest per coordinate, since re-curating is a newer event under the same d. Each card names the list, gives the queue row's glance of what the entry is, credits the suggester, and dates the group's decision; under it, the same broadcast button every signed event in the block has, gated on nothing. There is no way to add one here, because an entry is accepted from the queue on the schema's card above, and the empty state says so. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@732a4527c60aaa3702a5aebe7c90ded2a159b920 --- .../composeResources/values/strings.xml | 5 + .../repository/DatabaseChatRepository.kt | 16 + .../mantra/compose/nostr/GroupCuratedEntry.kt | 134 ++++++++ .../compose/repository/ChatRepository.kt | 18 + .../ui/composable/ChatRoomDetailScreen.kt | 145 +++++++- .../composable/CuratedSuggestionListScreen.kt | 7 +- .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../ui/view/state/ChatRoomDetailUIState.kt | 11 + .../compose/nostr/GroupCuratedEntryTest.kt | 317 ++++++++++++++++++ .../GroupNostrProfileSectionJvmTest.kt | 130 +++++++ 10 files changed, 777 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4644ca10..3651aa15 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -611,6 +611,11 @@ Must match %1$s. At least one of these is needed: %1$s. %1$s: %2$s + + Curated entries + This group hasn't accepted anything into a list yet. An entry a quorum signs is shown here, and reaches the list's readers once it is broadcast. + In %1$s + Suggested by %1$s Broadcast Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 40f4f4ef..fb511cec 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -10,8 +10,10 @@ import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet @@ -183,6 +185,20 @@ class DatabaseChatRepository( emptyList() } + override suspend fun groupCuratedEntries(chatRoomId: String): List = try { + // The schemas first, because an entry is read against the list it + // replies to and there is nothing to read one by without them. + GroupCuratedEntry.newestPerEntryAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, CuratedEntryEvent.CANONICAL_KIND), + chatRoomId = chatRoomId, + schemas = groupCuratedSchemas(chatRoomId), + ) + } catch (e: Throwable) { + logger.e("Error reading the curated entries of $chatRoomId", e) + emptyList() + } + override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId } } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt new file mode 100644 index 00000000..04caf549 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import kotlin.time.Instant + +/** + * A canonical entry the group signed: a suggestion it accepted into one of + * the lists it curates, as the list's own word. + * + * Kind 31890 -- see [CuratedEntryEvent] -- authored by the room's own key, + * which is the one key a list's readers accept a canonical entry from. Where + * the schema is the group asking and the queue is what strangers answered, + * this is what the group *decided*: the entries the list stands behind, each + * one a quorum's signature over a suggestion, edited or not, with a pointer + * back at who suggested it. + * + * ### Read against the list it replies to + * + * An entry replies to a schema by coordinate, so the reading needs the + * group's schemas as well as its signed events: the one whose coordinate the + * entry's `a` root names is the list it belongs to, and the entry is then + * checked against that list's fields exactly as `CuratedSuggestion.queueOf` + * checks a copy that came back off a relay. The two readings agree by + * construction, so what this screen shows as accepted is what the queue will + * mark as curated once the entry has been broadcast and read back. + * + * An entry replying to a list this device holds no schema for is not shown: + * without the schema there are no fields to read it by, and no way to say + * which list it is in. + * + * ### One per coordinate + * + * The kind is addressable, so re-curating an entry -- to take in a + * suggester's edit, or to fix the group's own -- is a newer event under the + * same `d`, and the newest is the entry the way the newest schema is the + * list. The coordinate is `31890::` whatever list the entry replies + * to, so the same `d` accepted into two of the group's lists is one + * coordinate and the newer event replaces the older, on relays and here + * alike. An identifier is the entry's rather than the list's, which is the + * reference client's reading too: its `d` is an external id that names the + * same film in every list. + * + * ### Nothing here has reached a relay + * + * The same caveat as the schema, and here it is the one that matters: the + * queue screen marks a suggestion curated only from a copy read back off the + * list's relays, so until a member broadcasts an entry from this section the + * queue still shows it waiting. The broadcast button under each card is how + * it gets there. + */ +data class GroupCuratedEntry( + /** The group's statement, whole, with the signature that makes it one. */ + val signedEvent: GroupSignedEvent, + /** The list the entry is in, as the group last signed its schema. */ + val list: GroupCuratedSchema, + /** What the statement says, keyed by the list's fields, and where it came from. */ + val entry: CuratedEntry, +) { + /** The group's nostr identity, which for a derived room is also its id. */ + val publicKey: HexKey get() = signedEvent.publicKey + + /** When the group signed it, which is the order entries are read in. */ + val signedAt: Instant get() = signedEvent.createdAt + + /** The entry's `d`, which with the group's key is its coordinate. */ + val identifier: String get() = entry.identifier + + val title: String get() = entry.title + + /** `31890::`. Two events sharing one are versions of one entry. */ + fun coordinate(): String = entry.coordinate() + + companion object { + /** + * The reading of one signed event against the group's [schemas], or + * null when it is not one of these. + * + * Four ways to be null and they are all the same refusal: the wrong + * kind, a signature that does not check out as [chatRoomId]'s, a root + * naming no list this device holds, or an entry the list's own fields + * refuse. A caller gets an entry the group really signed into a list it + * really curates, or gets nothing. + */ + fun of( + signedEvent: GroupSignedEvent, + chatRoomId: String, + schemas: List, + ): GroupCuratedEntry? { + if (signedEvent.kind != CuratedEntryEvent.CANONICAL_KIND) return null + if (!signedEvent.verifies()) return null + + val list = schemas.firstOrNull { it.isRepliedToBy(signedEvent.tags) } ?: return null + + val entry = CuratedEntryEvent.canonical( + event = signedEvent.toEvent(), + schema = list.schema, + coordinate = list.coordinate(), + curator = list.publicKey, + ) ?: return null + + return GroupCuratedEntry(signedEvent = signedEvent, list = list, entry = entry) + } + + /** + * The newest version of every entry [chatRoomId] signed among + * [signedEvents], most recently signed first. + * + * Newest per coordinate because the kind is addressable and a + * re-curation is a newer event under the same `d`; newest first so that + * what the group last decided is at the top, and the id breaking every + * tie so that two devices reading the same events in different orders + * show the same thing. + */ + fun newestPerEntryAmong( + signedEvents: List, + chatRoomId: String, + schemas: List, + ): List = + signedEvents + .mapNotNull { of(it, chatRoomId, schemas) } + .groupBy { it.coordinate() } + .values + .map { versions -> + versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id })) + } + .sortedWith( + compareByDescending { it.signedAt } + .thenByDescending { it.signedEvent.id } + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 686f8091..4f4533d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList @@ -125,6 +126,19 @@ interface ChatRepository { */ suspend fun groupCuratedSchemas(chatRoomId: String): List + /** + * The canonical entries this group signed -- the suggestions it accepted + * into the lists it curates -- one per coordinate, most recently signed + * first. + * + * Kind 31890 is addressable, so a re-curation is a newer event under the + * same identifier and the newest wins, the way a schema's edit does. Each + * is read against the schema it replies to, and an entry whose list this + * device holds no schema for is left out. Empty in a group that has + * accepted nothing, which is every group until a quorum signs an entry. + */ + suspend fun groupCuratedEntries(chatRoomId: String): List + /** * One event the group signed, as the group signed it, or null when this * device does not hold it or it was signed in some other room. @@ -328,6 +342,10 @@ interface ChatRepository { chatRoomId: String ): List = emptyList() + override suspend fun groupCuratedEntries( + chatRoomId: String + ): List = emptyList() + override suspend fun groupSignedEvent( chatRoomId: String, id: HexKey diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 65e4ea70..2540e326 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever @@ -91,6 +92,7 @@ import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfil import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute +import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toFormattedTimeAndDateString import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -113,7 +115,11 @@ import mantra.composeapp.generated.resources.relays import mantra.composeapp.generated.resources.posts import mantra.composeapp.generated.resources.add_post import mantra.composeapp.generated.resources.a_reply +import mantra.composeapp.generated.resources.curated_entries import mantra.composeapp.generated.resources.curated_schemas +import mantra.composeapp.generated.resources.in_list +import mantra.composeapp.generated.resources.suggested_by +import mantra.composeapp.generated.resources.this_group_has_not_accepted_anything_into_a_list_yet import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet import mantra.composeapp.generated.resources.add_schema import mantra.composeapp.generated.resources.view_suggestions @@ -143,6 +149,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost @@ -588,10 +595,8 @@ fun ChatRoomDetailScreen( // thing a group publishes for strangers to answer: // a post is the group speaking, a schema is the // group asking -- for entries to a list it will - // then curate, by quorum, under the same key. It - // closes the identity block because everything in - // that block is signed by that key and read by - // people who were never in the room. + // then curate, by quorum, under the same key. What + // it curated follows, in the section below. item { Text( text = stringResource(Res.string.curated_schemas), @@ -727,6 +732,57 @@ fun ChatRoomDetailScreen( } } + // Under the schemas, because these are what a + // schema is *for*: the schema asks, strangers + // answer on the queue, and these are the answers + // the group put its key to. They close the + // identity block because everything in it is + // signed by that key and read by people who were + // never in the room, and an accepted entry is the + // last thing in that chain. + item { + Text( + text = stringResource(Res.string.curated_entries), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.groupCuratedEntries.isEmpty()) { + item { + Text( + stringResource( + Res.string.this_group_has_not_accepted_anything_into_a_list_yet + ) + ) + } + } else { + items( + items = chatRoomDetailUIState.groupCuratedEntries, + key = { accepted -> accepted.signedEvent.id } + ) { accepted -> + // The card and its broadcast as one item, + // so the button stays under the entry it + // sends. No gate on either: reading what + // the group decided takes no share of the + // key, and neither does repeating its + // signature to a relay. There is no way + // in to add one here, because an entry + // is accepted from the queue it was + // suggested to, on the schema's card + // above. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupCuratedEntryCard(accepted = accepted) + + BroadcastButton( + onClick = { broadcast(accepted.signedEvent) } + ) + } + } + } + // The untyped way into everything above it. Each // section has a form that builds one kind of event; // this takes any of those kinds as JSON, pasted from @@ -1835,6 +1891,87 @@ private fun GroupCuratedSchemaCard( } } +/** + * One entry the group accepted into a list it curates, as the list's own word. + * + * The title first, because every list has one; then which list, since a group + * may curate several and an entry says nothing about its list on its face; + * then the glance the queue row gives -- the fields that say what the entry + * *is*, labelled as the schema labels them, and not the links -- and the body + * where the list has one. The last line credits the suggester, when there was + * one, and dates the group's decision, which is the fact a member reading the + * block came for. + * + * Not a way in to anything: the entry is revised from the queue it was + * suggested to, where the suggestion it came from and the "Curated" mark sit + * side by side, and the card's one action is the broadcast under it. + */ +@Composable +private fun GroupCuratedEntryCard(accepted: GroupCuratedEntry) { + val schema = accepted.list.schema + val entry = accepted.entry + + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + }, + headlineContent = { + Text( + text = accepted.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = stringResource(Res.string.in_list, schema.displayName()), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + glanceOf(entry, schema)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + bodyOf(entry, schema)?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = listOfNotNull( + entry.source?.let { stringResource(Res.string.suggested_by, it.pubKey.shortened()) }, + stringResource( + Res.string.signed_by_the_group_on, + accepted.signedAt.toFormattedTimeAndDateString() + ) + ).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + /** * One subgroup, as the parent's record and this device's rooms together have it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt index e126cc8a..06de3aa2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt @@ -418,9 +418,10 @@ private fun CuratedMark() { * The fields a form prompts for, less the title (the headline), the body (its * own line) and anything that is a link (the sheet). Labelled because a bare * "2014 · en" says nothing to somebody who does not know the list's fields, - * and this screen is driven by a schema it has never seen before. + * and this screen is driven by a schema it has never seen before. Shared with + * the group's screen, so an entry is described the same way on both. */ -private fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = +internal fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = schema.formFields() .filterNot { it.tag() == TITLE_TAG || it.tag() == CuratedFieldConfig.CONTENT_TAG } .filterNot { it.type == CuratedFieldType.Url || it.type == CuratedFieldType.Image } @@ -431,7 +432,7 @@ private fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = ?.joinToString(" · ") /** The value of the field that writes to the body, or null. */ -private fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = +internal fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = schema.fields.firstOrNull { it.tag() == CuratedFieldConfig.CONTENT_TAG }?.let { entry.value(it.name) } private const val TITLE_TAG = "title" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 7844c5ce..ec1f9afc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -92,6 +92,7 @@ class ChatRoomDetailViewModel( groupRelayLists = chatRepository.groupRelayLists(chatRoomId), groupPosts = chatRepository.groupPosts(chatRoomId), groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId), + groupCuratedEntries = chatRepository.groupCuratedEntries(chatRoomId), canEditNostrProfile = canSign, canAddSubgroup = canSign, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 601d993a..efc4e4af 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -7,6 +7,7 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost @@ -96,6 +97,16 @@ sealed interface ChatRoomDetailUIState { * proposes a schema. */ val groupCuratedSchemas: List = emptyList(), + /** + * The entries this group has accepted into the lists it curates, most + * recently signed first. + * + * Under the schemas because they are what a schema is *for*: the schema + * asks, strangers suggest, and these are what the group decided to + * stand behind. Empty in a group that has accepted nothing, which is + * every group until a quorum signs an entry. + */ + val groupCuratedEntries: List = emptyList(), /** * Whether this device could sign a profile for the group. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt new file mode 100644 index 00000000..e65a72db --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt @@ -0,0 +1,317 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSuggestionRef +import press.mantra.compose.nostr.curated.CuratedVisibility +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * What may be shown as an entry the group accepted into a list it curates. + * + * The reading gate is `GroupCuratedSchemaTest`'s -- the room has to be the + * author and the signature has to be the room's -- plus the two things this + * reader alone decides. An entry is read against the list it replies to, so + * one replying to a list this device holds no schema for is not shown, and one + * the list's own fields refuse is not shown either: it is the check the queue + * makes on a relay's copy, made here on the group's own, so the two screens + * cannot disagree about what the group accepted. + * + * The ordering half is the schema's: one per coordinate, newest winning, since + * re-curating is a newer event under the same `d`. + */ +class GroupCuratedEntryTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the entries signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + private val films = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle, with a link to watch each one.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), + CuratedField( + name = "watchUrl", + type = CuratedFieldType.Url, + isRequired = true, + label = "Where to watch", + config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true), + ), + ), + relays = listOf("wss://relay.example.com"), + ) + + private val books = films.copy(identifier = "books", name = "Books worth translating") + + /** The lists as the group's screen holds them: read off the group's real signatures. */ + private val schemas: List = listOf(films, books).map { schema -> + assertNotNull(GroupCuratedSchema.of(signed(CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000)), chatRoomId)) + } + + private val filmsList = schemas.first { it.identifier == "films" } + private val booksList = schemas.first { it.identifier == "books" } + + private fun riseAndRise(year: String = "2014") = mapOf( + "identifier" to listOf("imdb:tt2821314"), + "title" to listOf("The Rise and Rise of Bitcoin"), + "year" to listOf(year), + "watchUrl" to listOf("https://bitcoindoc.com/"), + ) + + private val source = CuratedSuggestionRef( + id = "a".repeat(64), + coordinate = "31888:$suggester:imdb:tt2821314", + pubKey = suggester, + ) + + private fun entry( + list: GroupCuratedSchema = filmsList, + values: Map> = riseAndRise(), + createdAt: Long = 1_700_000_900, + ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate( + schema = list.schema, + coordinate = list.coordinate(), + curator = list.publicKey, + values = values, + source = source, + createdAt = createdAt, + ) + + @Test + fun `an entry the room signed into a list it curates is one it accepted`() { + val accepted = assertNotNull(GroupCuratedEntry.of(signed(entry()), chatRoomId, schemas)) + + assertEquals("The Rise and Rise of Bitcoin", accepted.title) + assertEquals("imdb:tt2821314", accepted.identifier) + assertEquals("31890:$chatRoomId:imdb:tt2821314", accepted.coordinate()) + assertEquals(filmsList, accepted.list) + assertEquals("2014", accepted.entry.value("year")) + assertEquals(source, accepted.entry.source) + } + + @Test + fun `an entry another group signed is not this group's`() { + val stranger = GroupSignedEvent.fromEvent( + event = event(entry(), material = strangerMaterial), + chatRoomId = chatRoomId + ) + + assertNull(GroupCuratedEntry.of(stranger, chatRoomId, schemas)) + } + + @Test + fun `an entry the room did not sign is not one`() { + // Authored by the room and signed by somebody else: a member declaring + // the group accepted something, which the reading verifies rather than + // trusting the author field. + assertNull(GroupCuratedEntry.of(signed(entry(), signer = strangerMaterial), chatRoomId, schemas)) + + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupCuratedEntry.of(signed(entry(), signature = rubbish), chatRoomId, schemas), + "an entry signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `an event of another kind is not an entry`() { + assertNull( + GroupCuratedEntry.of(signed(entry()).copy(kind = CuratedEntryEvent.SUGGESTION_KIND), chatRoomId, schemas) + ) + } + + @Test + fun `an entry into a list this device holds no schema for is not shown`() { + // Really signed, and replying to a real list -- just not one among the + // schemas the reader was given. There are no fields to read it by. + assertNull(GroupCuratedEntry.of(signed(entry(list = booksList)), chatRoomId, schemas = listOf(filmsList))) + } + + @Test + fun `an entry the list's own fields refuse is not shown`() { + // The room's real signature over a year outside the schema's range: the + // group's statement, and still not an entry the list's readers would + // count. The queue would drop the relay's copy for the same reason. + val outOfRange = signed(entry(values = riseAndRise(year = "1850"))) + + assertNull(GroupCuratedEntry.of(outOfRange, chatRoomId, schemas)) + assertEquals(emptyList(), GroupCuratedEntry.newestPerEntryAmong(listOf(outOfRange), chatRoomId, schemas)) + } + + @Test + fun `the newest per coordinate wins, and two identifiers are two entries`() { + val first = signed(entry(createdAt = 1_700_000_900)) + val revised = signed(entry(values = riseAndRise(year = "2015"), createdAt = 1_700_001_800)) + val another = signed( + entry( + values = mapOf( + "identifier" to listOf("magic-money"), + "title" to listOf("Magic Money"), + "watchUrl" to listOf("https://example.com/watch"), + ), + createdAt = 1_700_001_200, + ) + ) + + listOf( + listOf(first, revised, another), + listOf(another, revised, first), + listOf(revised, another, first), + ).forEach { events -> + val accepted = GroupCuratedEntry.newestPerEntryAmong(events, chatRoomId, schemas) + + assertEquals( + listOf("imdb:tt2821314", "magic-money"), + accepted.map { it.identifier }, + "the entries came back in the wrong order, or one of them went missing" + ) + assertEquals("2015", accepted.first().entry.value("year"), "a re-curation did not replace the entry it revised") + } + } + + @Test + fun `the same identifier in two lists is one coordinate, and the newer decides`() { + val inFilms = signed(entry(list = filmsList, createdAt = 1_700_000_900)) + val inBooks = signed(entry(list = booksList, createdAt = 1_700_001_800)) + + val accepted = GroupCuratedEntry.newestPerEntryAmong(listOf(inFilms, inBooks), chatRoomId, schemas) + + assertEquals(1, accepted.size, "one coordinate was shown as two entries") + assertEquals(booksList, accepted.single().list) + } + + /** An entry as `FrostSigningManager.complete` files one. */ + private fun signed( + template: EventTemplate<*>, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = event(template, material, signer, signature), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun event( + template: EventTemplate<*>, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index 733dd80a..137aa291 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -16,15 +16,20 @@ import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelay import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldType import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSuggestionRef import press.mantra.compose.nostr.curated.CuratedVisibility import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import press.mantra.compose.repository.ChatRepository @@ -281,6 +286,79 @@ class GroupNostrProfileSectionJvmTest { assertTrue(books.top < second.top, "the second queue button is not under its card") } + @Test + fun `the curated entries sit under the schemas and above the group's work`() = render( + state( + groupNostrProfile = profile, + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), + entries = listOf( + entry("The Rise and Rise of Bitcoin", 1_700_000_900), + entry("Magic Money", 1_700_000_300, suggestedBy = null) + ) + ) + ) { + val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top + val entries = onNodeWithText("Curated entries").getBoundsInRoot().top + val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top + + assertTrue(schemas < entries, "the curated entries climbed above the schemas") + assertTrue(entries < subgroups, "the curated entries fell below the group's work") + + // Most recently signed first, one card per entry. + val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top + assertTrue(newest < oldest, "the entries were drawn oldest first") + + // Which list, what the entry is -- labelled as the schema labels it -- + // and who suggested it, where somebody did. + onAllNodesWithText("In Films worth translating").assertCountEquals(2) + onAllNodesWithText("Year: 2014", substring = true).assertCountEquals(2) + onAllNodesWithText("Suggested by eebb74ab · ", substring = true).assertCountEquals(1) + // Dated like every signed statement in the block: the profile, the + // schema, and the two entries. + onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(4) + } + + @Test + fun `a group that has accepted nothing says so, and offers no way to add one here`() = render( + state( + groupNostrProfile = profile, + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)) + ) + ) { + onNodeWithText("Curated entries").assertIsDisplayed() + onNodeWithText("This group hasn't accepted anything into a list yet.", substring = true) + .assertIsDisplayed() + + // An entry is accepted from the queue on the schema's card, not from + // this section, so the section has no button of its own. + onNodeWithText("View suggestions").assertIsDisplayed() + onAllNodesWithText("Add entry").assertCountEquals(0) + } + + @Test + fun `every accepted entry offers a broadcast under its own card, share or no share`() = render( + state( + groupNostrProfile = profile, + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), + entries = listOf(entry("The Rise and Rise of Bitcoin", 1_700_000_900)), + canEditNostrProfile = false + ) + ) { + // The profile's, the schema's and the entry's: the entry is the one + // event in the block whose broadcast the queue screen depends on, since + // that is where the "Curated" mark is read back from. + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(3) + + val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top + val entryCard = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val last = buttons[2].getBoundsInRoot().top + + assertTrue(schemaCard < entryCard, "the entry climbed above its schema") + assertTrue(entryCard < last, "the entry's broadcast is not under its card") + } + @Test fun `every signed event in the block offers a broadcast, under its own card`() = render( state( @@ -472,11 +550,62 @@ class GroupNostrProfileSectionJvmTest { ) ) + /** + * An entry the group accepted into the films list, built rather than read + * out of a signed event, for the reason [schema] is. The list's schema has a + * year, so the card has a glance to give. + */ + private fun entry( + title: String, + createdAt: Long, + suggestedBy: String? = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + ): GroupCuratedEntry { + val list = schema("films", "Films worth translating", 1_700_000_000).let { films -> + films.copy( + schema = films.schema.copy( + fields = films.schema.fields + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year") + ) + ) + } + val identifier = title.lowercase().replace(' ', '-') + + return GroupCuratedEntry( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'd'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + list = list, + entry = CuratedEntry( + kind = CuratedEntryEvent.CANONICAL_KIND, + id = createdAt.toString().padStart(64, 'd'), + pubKey = chatRoomId, + createdAt = createdAt, + identifier = identifier, + title = title, + values = mapOf( + "identifier" to listOf(identifier), + "title" to listOf(title), + "year" to listOf("2014"), + ), + source = suggestedBy?.let { + CuratedSuggestionRef(id = "a".repeat(64), coordinate = "31888:$it:$identifier", pubKey = it) + } + ) + ) + } + private fun state( groupNostrProfile: GroupNostrProfile?, relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), posts: List = emptyList(), schemas: List = emptyList(), + entries: List = emptyList(), canEditNostrProfile: Boolean = true, mlsGroupState: String? = "state" ) = ChatRoomDetailUIState.Loaded( @@ -507,6 +636,7 @@ class GroupNostrProfileSectionJvmTest { groupRelayLists = relayLists, groupPosts = posts, groupCuratedSchemas = schemas, + groupCuratedEntries = entries, canEditNostrProfile = canEditNostrProfile ) From b5cd41cb3dc141157420d125012029ebfc9611da Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 13:55:42 +0200 Subject: [PATCH 25/71] fix(ui): a back button on every pushed screen, and one spelling of it Eighteen screens drew a back arrow in their app bar's leading slot and twenty-two pushed screens did not. On Android the system back stood in for it and on iOS the edge swipe, but the desktop target has neither, and a screen reached by `navigate(...)` with no way to pop it is a dead end there: seven group forms, six chat screens, five DKG screens, and four screens with no app bar at all. **One widget, `NavigateBackButton`, rather than a nineteenth inline copy.** The eighteen that had the button spelled it four ways -- `Icons.Filled.ArrowBack`, the auto-mirrored one, `ArrowBackIosNew`, and a `"Back"` literal against a `stringResource`. The widget decides twice: the icon is the auto-mirrored one, because "back" points at the leading edge and the leading edge is on the right in an RTL locale, which is what `Icons.Filled.ArrowBack` is deprecated for; and the description is the catalogue's, because it is text. All forty-one sites use it now, the eighteen converted mechanically with the imports they no longer need dropped. **Each screen takes `onNavigateBack` and the host passes `popBackStack()`.** Hoisted rather than read from a controller in the screen, so every one stays previewable and testable without a nav host, which is how the twenty-two were fixed without a nav host in a single test. **Four screens had no bar to put it in, and got one.** `ChatRoomCreationScreen` is "New chat", after the button that opens it. `CreateProfileScreen` is "Create profile", and the two body headlines that repeated the name are gone, which is the shape `SignInScreen` beside it on the landing page already has. `WriteNewNoteScreen` is "New note" whether it is a reply, a quote or neither, one new string. The "coming soon" placeholder's bar is titled with the name of what was tapped. `AddMemberToChatRoomConfirmationScreen`'s bar had been commented out; it is back, titled "Invite new member" since the body already names who and which room. `NostrEventDetailScreen`'s repost and unknown-kind branches were the last two placeholders without a bar. **Two screens are reached two ways, and only the caller knows which.** Their callback is nullable, and null draws no button. `ChatRoomMessagingScreen` is a destination on a phone and the home screen's detail pane in an expanded window; in the pane the room list is beside the transcript and a button that popped would pop the home screen, so the pane passes null. `ImplementationPendingScreen` is pushed from "learn more" and "edit profile" and is also where the navigation observer lands with `popUpTo(0)` on an error; the host reads `previousBackStackEntry`, remembered at first composition because the departing screen reads it again after the stack has moved on. The DKG approval screens reuse their existing `onDone`, which "Not now" already called -- the bar makes the same leave reachable from the loading and error states, which had no other way off. **Left alone on purpose.** The three top-level destinations have the navigation bar, which phase 6 put there instead of app-bar icons. The onboarding and loading screens cleared the stack to get where they are and have nothing under them. `SearchScreen` and `SearchResultScreen` keep their `ArrowBackIosNew`: it is a search bar's collapse control in a `leadingIcon` slot, not a navigation icon. `NavigateBackButtonJvmTest` covers the two conditional screens by the description a screen reader would announce: present and popping when pushed, absent in the pane and at the root. The rule is recorded in CLAUDE.md beside the others a new screen has to follow. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (938 tests, 4 new) and docs/scripts/m3-audit.sh --check, all budgets met. Replayed onto Mantra by docs/curated-to-mantra.md: CuratedSuggestionListScreen.kt: taken as the original merge fedbe724 left it, this being the branch join; AcceptCuratedSuggestionScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts; BroadcastGroupSignedEventScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@0634487e120975156f5d3720e2d6ef9f82e38cf1 --- CLAUDE.md | 22 ++++ .../composeResources/values/strings.xml | 2 + .../AcceptCuratedSuggestionScreen.kt | 8 +- .../ui/composable/ActiveProfileScreen.kt | 16 +-- .../ui/composable/AddArtifactScreen.kt | 9 +- .../compose/ui/composable/AddChapterScreen.kt | 12 +- .../compose/ui/composable/AddDialectScreen.kt | 9 +- .../ui/composable/AddGroupPostScreen.kt | 8 +- .../AddMemberToChatRoomConfirmationScreen.kt | 31 +++-- .../AddTranslationArtifactVersionScreen.kt | 12 +- .../ui/composable/ArtifactDetailScreen.kt | 12 +- .../BroadcastGroupSignedEventScreen.kt | 8 +- .../ui/composable/ChapterDetailScreen.kt | 12 +- .../ui/composable/ChatRoomCreationScreen.kt | 22 +++- .../ui/composable/ChatRoomDetailScreen.kt | 8 +- .../ui/composable/ChatRoomMessagingScreen.kt | 14 ++- .../ui/composable/CreateProfileScreen.kt | 31 +++-- .../composable/CuratedSuggestionListScreen.kt | 9 +- .../ui/composable/DkgApprovalScaffold.kt | 6 +- .../compose/ui/composable/DkgRitualScreen.kt | 8 +- .../EditGroupCuratedSchemaScreen.kt | 8 +- .../composable/EditGroupNostrProfileScreen.kt | 8 +- .../ui/composable/EditGroupRelaysScreen.kt | 8 +- .../ui/composable/FrostSigningScreen.kt | 9 +- .../compose/ui/composable/HomeScreen.kt | 2 + .../composable/ImplementationPendingScreen.kt | 29 ++++- .../composable/KeyPackageManagementScreen.kt | 15 +-- .../ui/composable/KeyRecoveryScreen.kt | 16 +-- .../ui/composable/NostrEventDetailScreen.kt | 40 +++++-- .../ui/composable/NostrSecretScreen.kt | 13 +- .../ui/composable/ProposalListScreen.kt | 9 +- .../ui/composable/ProposeGroupEventScreen.kt | 8 +- .../ui/composable/RecoveryPhraseScreen.kt | 16 +-- .../SearchMemberToAddToChatRoomScreen.kt | 8 +- .../composable/SelectChatRoomMembersScreen.kt | 8 +- .../ui/composable/SelectChatRoomTypeScreen.kt | 8 +- .../composable/SelectSubgroupAdminsScreen.kt | 12 +- .../ui/composable/ShareProfileScreen.kt | 16 +-- .../compose/ui/composable/SignInScreen.kt | 12 +- .../SovereignWalletStartupScreen.kt | 4 +- .../ui/composable/TranslateChunkScreen.kt | 12 +- .../TranslationArtifactVersionDetailScreen.kt | 12 +- .../ui/composable/TranslationChapterScreen.kt | 12 +- .../ui/composable/WriteNewNoteScreen.kt | 20 +++- .../ui/composable/navigation/MantraNavHost.kt | 75 +++++++++++- .../composable/widgets/NavigateBackButton.kt | 50 ++++++++ .../widgets/detail/MetadataEventDetail.kt | 14 +-- .../widgets/detail/TextNoteEventDetail.kt | 13 +- .../EditGroupCuratedSchemaScreenJvmTest.kt | 3 +- .../GroupNostrProfileSectionJvmTest.kt | 3 +- .../composable/NavigateBackButtonJvmTest.kt | 111 ++++++++++++++++++ .../ProposeGroupEventScreenJvmTest.kt | 3 +- 52 files changed, 536 insertions(+), 300 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/NavigateBackButton.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/NavigateBackButtonJvmTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 3c68fa57..de57265a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,28 @@ ScreenStateTransition(viewModel.uiState) { uiState -> It only works where the `when` is the composable's whole body — `AnimatedContent` is a layout node, so wrapping one inside a `Column` takes its branches out of `ColumnScope`. +## A pushed screen has a way back + +```kotlin +TopAppBar( + title = { Text(stringResource(Res.string.add_post)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, // yes +) +TopAppBar(title = { … }) // no, on a pushed screen +``` + +A screen reached by `navController.navigate(...)` takes an `onNavigateBack: () -> Unit` +and puts `NavigateBackButton` in its app bar's leading slot; the host passes +`popBackStack()`. Android has a system back and iOS an edge swipe, but the desktop target +has neither, and a pushed screen with no button there is a dead end. Twenty-two were. + +The button is *only* for pushed screens. The three top-level destinations have the +navigation bar instead, and a screen that cleared the stack to get there -- onboarding, +the loading gate -- has nothing under it to pop to. Where one composable is reached both +ways, the callback is nullable and the caller decides: `ChatRoomMessagingScreen` is a +destination on a phone and the home screen's detail pane in a wide window, and in the +pane the list beside it is the way back. + ## Layout adapts to the window, not to the composable ```kotlin diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3651aa15..f74dd05c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -679,4 +679,6 @@ You have not backed up your nostr secret key These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone. You said you stored it + + New note diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt index 9b21092f..dd0d5544 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Delete @@ -111,6 +110,7 @@ import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.ConformancePreviews @@ -221,11 +221,7 @@ fun AcceptCuratedSuggestionScreen( topBar = { TopAppBar( title = { state.localChatRoom.RenderChatRoomTitleText() }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) }, bottomBar = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index ecbb13ae..6f4b486f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Bento import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Hub @@ -23,7 +22,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -72,6 +70,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ButtonDefaults import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -109,18 +108,7 @@ fun ActiveProfileScreen( text = stringResource(Res.string.profile) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 693afa49..60024cbb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -84,6 +84,7 @@ import mantra.composeapp.generated.resources.source_dialect import mantra.composeapp.generated.resources.url import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -101,6 +102,7 @@ fun AddArtifactScreen( frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val addArtifactViewModel: AddArtifactViewModel = viewModel( factory = AddArtifactViewModel.factory( @@ -160,9 +162,7 @@ fun AddArtifactScreen( title = { addArtifactUIState.localChatRoom.RenderChatRoomTitleText() }, - actions = { - - } + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -430,7 +430,8 @@ private fun AddArtifactScreenPreview() { mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index f46431ef..4fa54ce0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material3.BottomAppBar @@ -19,7 +18,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -67,6 +65,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -146,14 +145,7 @@ fun AddChapterScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index f352dd58..ebd8d934 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -68,6 +68,7 @@ import mantra.composeapp.generated.resources.language import mantra.composeapp.generated.resources.propose_dialect import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -84,6 +85,7 @@ fun AddDialectScreen( frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val addDialectViewModel: AddDialectViewModel = viewModel( factory = AddDialectViewModel.factory( @@ -131,9 +133,7 @@ fun AddDialectScreen( title = { addDialectUIState.localChatRoom.RenderChatRoomTitleText() }, - actions = { - - } + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -364,7 +364,8 @@ private fun AddDialectScreenPreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt index ac0bdc77..208b9346 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt @@ -52,6 +52,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.rememberNotifier @@ -96,6 +97,7 @@ fun AddGroupPostScreen( chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val addGroupPostViewModel: AddGroupPostViewModel = viewModel( factory = AddGroupPostViewModel.factory( @@ -138,7 +140,8 @@ fun AddGroupPostScreen( modifier = Modifier.imePadding(), topBar = { TopAppBar( - title = { Text(text = stringResource(Res.string.add_post)) } + title = { Text(text = stringResource(Res.string.add_post)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -326,7 +329,8 @@ private fun AddGroupPostScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {} + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt index d8c93b38..21f5dffc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -43,10 +44,12 @@ import mantra.composeapp.generated.resources.direct_message_detail import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here import mantra.composeapp.generated.resources.invite import mantra.composeapp.generated.resources.invite_2 +import mantra.composeapp.generated.resources.invite_new_member import mantra.composeapp.generated.resources.once_invited_will_be_able_to_receive_and import mantra.composeapp.generated.resources.to_join_the_chat_room import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -62,6 +65,7 @@ fun AddMemberToChatRoomConfirmationScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, onInviteSent: () -> Unit, + onNavigateBack: () -> Unit, ) { val addMemberToChatRoomConfirmationViewModel: AddMemberToChatRoomConfirmationViewModel = viewModel( factory = AddMemberToChatRoomConfirmationViewModel.factory( @@ -94,22 +98,14 @@ fun AddMemberToChatRoomConfirmationScreen( is AddMemberToChatRoomConfirmationUIState.Loaded -> { Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - // topBar = { - // TopAppBar( - // title = { - // val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) { - // "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}" - // } else { - // "Invite new member to chat" - // } - // Text( - // text = title, - // ) - // }, - // actions = { - // } - // ) - // } + topBar = { + // The body already names who is being invited to which room, + // so the bar says only what the screen is for. + TopAppBar( + title = { Text(stringResource(Res.string.invite_new_member)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, bottomBar = { BottomAppBar( floatingActionButton = { @@ -252,7 +248,8 @@ private fun ChatRoomDetailScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onInviteSent = {} + onInviteSent = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 7f46159b..2dfc9a4a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ButtonDefaults @@ -19,7 +18,6 @@ import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -68,6 +66,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -145,14 +144,7 @@ fun AddTranslationArtifactVersionScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 442aba14..b8fd9da9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.PostAdd import androidx.compose.material.icons.filled.Translate @@ -19,7 +18,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -67,6 +65,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -111,14 +110,7 @@ fun ArtifactDetailScreen( topBar = { TopAppBar( title = { Text(artifact.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 17733468..cfc9e191 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CellTower import androidx.compose.material.icons.filled.ContentCopy @@ -98,6 +97,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.readableContent @@ -216,11 +216,7 @@ fun BroadcastGroupSignedEventScreen( title = { broadcastGroupSignedEventUIState.localChatRoom.RenderChatRoomTitleText() }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) }, bottomBar = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt index 37ae0687..fd5feb3b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt @@ -10,13 +10,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Segment import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -51,6 +49,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -94,14 +93,7 @@ fun ChapterDetailScreen( topBar = { TopAppBar( title = { Text(chapter.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt index 008dfb7c..2027e905 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt @@ -12,12 +12,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,19 +35,23 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.choose_who_to_chat_with import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your import mantra.composeapp.generated.resources.name_eg_group_discussions +import mantra.composeapp.generated.resources.new_chat import mantra.composeapp.generated.resources.this_will_be_shown_when_people_open_the_chat import mantra.composeapp.generated.resources.this_will_be_the_display_name_for_this_chat import mantra.composeapp.generated.resources.what_should_people_know_about_you import mantra.composeapp.generated.resources.what_will_be_discussed_in_this_chat_room import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatRoomCreationScreen( activeUserPublicKey: HexKey, - onNavigateToRoute: (Route) -> Unit + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val chatRoomCreationViewModel: ChatRoomCreationViewModel = viewModel ( factory = ChatRoomCreationViewModel.factory() @@ -55,7 +61,16 @@ fun ChatRoomCreationScreen( // keyboard covers whatever is being typed into. Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding()) { innerPadding -> + modifier = Modifier.imePadding(), + topBar = { + // Named after the button that opens it, which is the first of three steps; + // the next two name themselves after the chat this one is creating. + TopAppBar( + title = { Text(stringResource(Res.string.new_chat)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding).readableContent().fillMaxWidth() ) { @@ -181,7 +196,8 @@ private fun LoadingScreenPreview() { ) { ChatRoomCreationScreen( activeUserPublicKey = "", - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 2540e326..b2148a64 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -192,6 +192,7 @@ import mantra.composeapp.generated.resources.recovered_of_event_s import mantra.composeapp.generated.resources.recovered_of_still_unreadable import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -208,7 +209,8 @@ fun ChatRoomDetailScreen( chatRepository: ChatRepository, mantraRepository: MantraRepository, onNavigateToRoute: (Route) -> Unit, - onPopBackToRoute: (Route) -> Unit + onPopBackToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val chatRoomDetailViewModel: ChatRoomDetailViewModel = viewModel( factory = ChatRoomDetailViewModel.factory( @@ -245,6 +247,7 @@ fun ChatRoomDetailScreen( title = { chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -2213,7 +2216,8 @@ private fun ChatRoomMessagingScreenPreview() { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, onNavigateToRoute = {}, - onPopBackToRoute = {} + onPopBackToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index e2a70c8f..e678a623 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.private_message_to import mantra.composeapp.generated.resources.private_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -86,6 +87,13 @@ fun ChatRoomMessagingScreen( dkgRepository: DkgRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, + /** + * `null` when this is the home screen's detail pane rather than a pushed route. In + * the pane the room list is beside the transcript and there is nothing to pop, so + * the bar draws no back button -- M3's list-detail has the list do that job. On a + * phone the same screen is a destination, and the button is what leaves it. + */ + onNavigateBack: (() -> Unit)?, ) { val chatRoomMessagingViewModel: ChatRoomMessagingViewModel = viewModel( factory = ChatRoomMessagingViewModel.factory( @@ -133,6 +141,9 @@ fun ChatRoomMessagingScreen( title = { chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() }, + navigationIcon = { + if (onNavigateBack != null) NavigateBackButton(onNavigateBack) + }, actions = { IconButton( onClick = { @@ -491,7 +502,8 @@ private fun ChatRoomMessagingScreenPreview() { frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt index da11d54a..28cc9ee3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.Description import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -23,6 +24,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,14 +62,16 @@ import mantra.composeapp.generated.resources.you_are_about_to_create_a_nostr_pro import mantra.composeapp.generated.resources.you_will_be_in_full_control_of_this_profile import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun CreateProfileScreen( initialCreateProfileUIState: CreateProfileUIState = CreateProfileUIState.Declaration, onNavigateToEndThis: () -> Unit, + onNavigateBack: () -> Unit, nostrRepository: press.mantra.compose.repository.NostrRepository, marmotRepository: MarmotRepository, writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit @@ -83,7 +87,18 @@ fun CreateProfileScreen( // keyboard covers whatever is being typed into. Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding()) { innerPadding -> + modifier = Modifier.imePadding(), + topBar = { + // The name is in the bar, as it is on the sign-in screen beside this one on + // the landing page, and the steps below no longer repeat it as a headline. + // Back pops to the landing page; nothing is written until the last step + // confirms, so there is nothing to lose by leaving. + TopAppBar( + title = { Text(stringResource(Res.string.create_profile)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) .readableContent() @@ -99,12 +114,6 @@ fun CreateProfileScreen( when (val createAccountUIState = createProfileViewModel.createProfileUIState.value) { is CreateProfileUIState.Declaration -> { - - Text( - stringResource(Res.string.create_profile), - style = MaterialTheme.typography.headlineSmall - ) - Text( stringResource(Res.string.you_are_about_to_create_a_nostr_profile), style = MaterialTheme.typography.bodyMedium, @@ -133,11 +142,6 @@ fun CreateProfileScreen( } } is CreateProfileUIState.InputPrompt -> { - Text( - stringResource(Res.string.create_profile), - style = MaterialTheme.typography.headlineSmall - ) - TextField( modifier = Modifier.fillMaxWidth(), state = createProfileViewModel.createProfileFormState.nameField.textFieldState, @@ -438,6 +442,7 @@ fun CreateAccountScreenPreview() { // ) // ), onNavigateToEndThis = {}, + onNavigateBack = {}, writeSeed = { _, _, _ -> }, nostrRepository = press.mantra.compose.repository.NostrRepository.NO_OP_NOSTR_REPOSITORY, marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt index 06de3aa2..8f7b69c6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Draw import androidx.compose.material3.Button @@ -20,7 +19,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -85,6 +83,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.theme.readableContent @@ -153,11 +152,7 @@ fun CuratedSuggestionListScreen( overflow = TextOverflow.Ellipsis ) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { padding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index b3766198..cc358aa5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -45,6 +45,7 @@ import mantra.composeapp.generated.resources.back import mantra.composeapp.generated.resources.not_now import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent /** @@ -92,7 +93,10 @@ internal fun DkgApprovalScaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { TopAppBar( - title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) } + title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + // The same leave as "Not now" below, reachable from the loading and error + // states too -- which have no other way off the screen on desktop. + navigationIcon = { NavigateBackButton(onDone) }, ) } ) { padding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index 342b6702..64ce688d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -114,6 +114,7 @@ import mantra.composeapp.generated.resources.of_members_will_be_needed_to_sign_w import mantra.composeapp.generated.resources.shared_key_for import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -139,6 +140,7 @@ fun DkgRitualScreen( chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit = {}, + onNavigateBack: () -> Unit, ) { val dkgRitualViewModel: DkgRitualViewModel = viewModel( factory = DkgRitualViewModel.factory( @@ -179,7 +181,8 @@ fun DkgRitualScreen( maxLines = 1, overflow = TextOverflow.Ellipsis ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -884,7 +887,8 @@ private fun DkgRitualScreenPreview() { ), activeIdentityStateFlow = MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt index be868d9b..6a113f52 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt @@ -104,6 +104,7 @@ import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet import press.mantra.compose.ui.composable.widgets.rememberNotifier @@ -268,6 +269,7 @@ fun EditGroupCuratedSchemaScreen( chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val editGroupCuratedSchemaViewModel: EditGroupCuratedSchemaViewModel = viewModel( factory = EditGroupCuratedSchemaViewModel.factory( @@ -344,7 +346,8 @@ fun EditGroupCuratedSchemaScreen( TopAppBar( title = { editGroupCuratedSchemaUIState.localChatRoom.RenderChatRoomTitleText() - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -1514,7 +1517,8 @@ private fun EditGroupCuratedSchemaScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {} + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt index 33e8d287..6914b722 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt @@ -59,6 +59,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.ConformancePreviews @@ -113,6 +114,7 @@ fun EditGroupNostrProfileScreen( chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val editGroupNostrProfileViewModel: EditGroupNostrProfileViewModel = viewModel( factory = EditGroupNostrProfileViewModel.factory( @@ -170,7 +172,8 @@ fun EditGroupNostrProfileScreen( title = { editGroupNostrProfileUIState.localChatRoom .RenderChatRoomTitleText() - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -416,7 +419,8 @@ private fun EditGroupNostrProfileScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {} + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt index 6a0003f3..7b076c84 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt @@ -68,6 +68,7 @@ import press.mantra.compose.ui.composable.widgets.EmptyState import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.ConformancePreviews @@ -133,6 +134,7 @@ fun EditGroupRelaysScreen( chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val editGroupRelaysViewModel: EditGroupRelaysViewModel = viewModel( factory = EditGroupRelaysViewModel.factory( @@ -192,7 +194,8 @@ fun EditGroupRelaysScreen( TopAppBar( title = { editGroupRelaysUIState.localChatRoom.RenderChatRoomTitleText() - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -570,7 +573,8 @@ private fun EditGroupRelaysScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {} + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index f9917824..fbecea82 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.ErrorOutline @@ -25,7 +24,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -70,6 +68,7 @@ import mantra.composeapp.generated.resources.signed_their_part import mantra.composeapp.generated.resources.events_signed_together import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -125,11 +124,7 @@ fun FrostSigningScreen( overflow = TextOverflow.Ellipsis ) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { padding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index fc20e8ec..2b91c937 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -302,6 +302,8 @@ fun HomeScreen( // created and has an id at last. onNavigateToRouteAndPopUpInclusive = onOpenChatRoom, onNavigateToRoute = onNavigateToRoute, + // The list is beside it; there is nothing to pop. + onNavigateBack = null, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt index cd4c2015..98216fc5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt @@ -4,10 +4,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,15 +20,35 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.functionality_coming_soon_2 import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews +/** + * @param onNavigateBack `null` when this screen is the root of the stack, which it is + * when the navigation machine lands on an error and clears everything under it. Pushed + * from a "learn more" or an "edit profile", there is a screen to return to and this is + * how. Only the host knows which, so it decides. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImplementationPendingScreen( - text: String + text: String, + onNavigateBack: (() -> Unit)?, ) { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + // The name of the thing that is not built yet, which is what was tapped. + title = { Text(text) }, + navigationIcon = { + if (onNavigateBack != null) NavigateBackButton(onNavigateBack) + }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { @@ -52,7 +74,8 @@ private fun ImplementationPendingScreenPreview() { modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { ImplementationPendingScreen( - "Sign in" + text = "Sign in", + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt index e787472d..b45367e9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.LockReset import androidx.compose.material.icons.filled.Unpublished @@ -59,6 +58,7 @@ import mantra.composeapp.generated.resources.key_package_published import mantra.composeapp.generated.resources.key_package_rotated import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -102,18 +102,7 @@ fun KeyPackageManagementScreen( text = stringResource(Res.string.key_package_management) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index b7e12aa3..b70a798c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddToDrive -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.LocalHospital import androidx.compose.material.icons.filled.Key @@ -25,7 +24,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -71,6 +69,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -109,18 +108,7 @@ fun KeyRecoveryScreen( text = stringResource(Res.string.key_recovery) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt index 348a912a..f141fb24 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -34,12 +35,14 @@ import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.functionality_coming_soon +import mantra.composeapp.generated.resources.post import mantra.composeapp.generated.resources.post_functionality_coming_soon import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.we_couldn_t_find_your_nostr_event_please_try import press.mantra.compose.ui.composable.widgets.ErrorState import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -95,20 +98,41 @@ fun NostrEventDetailScreen( nostrRepository = nostrRepository ) } + // The two kinds with a detail of their own draw their bar inside it. The + // rest are a placeholder, and a placeholder pushed onto the stack still + // needs the way off it. RepostEvent.KIND -> { // TODO: RePost... - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(feedListUIState.localNostrEvent.nostrEvent.content) + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.post)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(feedListUIState.localNostrEvent.nostrEvent.content) - Text(stringResource(Res.string.post_functionality_coming_soon)) + Text(stringResource(Res.string.post_functionality_coming_soon)) + } } } else -> { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.post)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt index 12d49d6c..bf04707a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Visibility @@ -27,7 +26,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -48,7 +46,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.back import mantra.composeapp.generated.resources.backup_confirmation import mantra.composeapp.generated.resources.cancel import mantra.composeapp.generated.resources.copied_the_nsec @@ -86,6 +83,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -201,14 +199,7 @@ private fun NostrSecretContent( topBar = { TopAppBar( title = { Text(text = stringResource(Res.string.nostr_secret_key)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back) - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt index 2ac182c3..a330a4c3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Draw @@ -22,7 +21,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -64,6 +62,7 @@ import mantra.composeapp.generated.resources.waiting_for_you import mantra.composeapp.generated.resources.of_them_could_not_be_read import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -119,11 +118,7 @@ fun ProposalListScreen( overflow = TextOverflow.Ellipsis ) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { padding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt index b82b529e..6b4f4a86 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt @@ -58,6 +58,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.rememberNotifier @@ -115,6 +116,7 @@ fun ProposeGroupEventScreen( chatRepository: ChatRepository, frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val proposeGroupEventViewModel: ProposeGroupEventViewModel = viewModel( factory = ProposeGroupEventViewModel.factory( @@ -164,7 +166,8 @@ fun ProposeGroupEventScreen( modifier = Modifier.imePadding(), topBar = { TopAppBar( - title = { Text(text = stringResource(Res.string.propose_event)) } + title = { Text(text = stringResource(Res.string.propose_event)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -458,7 +461,8 @@ private fun ProposeGroupEventScreenPreview() { ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {} + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt index 415d9973..543a75ca 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.Warning @@ -23,7 +22,6 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -66,6 +64,7 @@ import org.jetbrains.compose.resources.stringResource import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -136,18 +135,7 @@ private fun RecoveryPhraseContent( text = stringResource(Res.string.recovery_phrase) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt index 67b25221..ea39c367 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt @@ -47,6 +47,7 @@ import mantra.composeapp.generated.resources.search_member_functionality import mantra.composeapp.generated.resources.search_message_functionality_will_be_here import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -61,6 +62,7 @@ fun SearchMemberToAddToChatRoomScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val searchMemberToAddToChatRoomViewModel: SearchMemberToAddToChatRoomViewModel = viewModel( factory = SearchMemberToAddToChatRoomViewModel.factory( @@ -102,7 +104,8 @@ fun SearchMemberToAddToChatRoomScreen( Text( text = title, ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> @@ -256,7 +259,8 @@ private fun ChatRoomDetailScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index f1aa9dbc..772a2ce9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -54,6 +54,7 @@ import mantra.composeapp.generated.resources.next_with import mantra.composeapp.generated.resources.selected import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -74,6 +75,7 @@ fun SelectChatRoomMembersScreen( initialSelectChatRoomMembersUIState: SelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loading, nostrRepository: NostrRepository, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val selectChatRoomMembersViewModel: SelectChatRoomMembersViewModel = viewModel( factory = SelectChatRoomMembersViewModel.factory( @@ -114,7 +116,8 @@ fun SelectChatRoomMembersScreen( maxLines = 1, overflow = TextOverflow.Ellipsis ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -302,7 +305,8 @@ private fun SelectChatRoomMembersScreenPreview() { ) ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 0d62827e..5f5399fd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -71,6 +71,7 @@ import mantra.composeapp.generated.resources.was_created_but_its_shared_key_cere import mantra.composeapp.generated.resources.you_and_others import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -92,6 +93,7 @@ fun SelectChatRoomTypeScreen( chatRepository: ChatRepository, dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel( factory = SelectChatRoomTypeViewModel.factory( @@ -142,7 +144,8 @@ fun SelectChatRoomTypeScreen( maxLines = 1, overflow = TextOverflow.Ellipsis ) - } + }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { @@ -494,7 +497,8 @@ private fun SelectChatRoomTypeScreenPreview() { nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index 94b311b8..44110cbf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -59,6 +59,7 @@ import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.ConformancePreviews @@ -84,7 +85,8 @@ fun SelectSubgroupAdminsScreen( activeIdentityStateFlow: StateFlow, chatRepository: ChatRepository, dkgRepository: DkgRepository, - onNavigateToRoute: (Route) -> Unit + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, ) { val viewModel: SelectSubgroupAdminsViewModel = viewModel( factory = SelectSubgroupAdminsViewModel.factory( @@ -115,7 +117,10 @@ fun SelectSubgroupAdminsScreen( is SelectSubgroupAdminsUIState.Loaded -> Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar(title = { Text(stringResource(Res.string.new_subgroup)) }) + TopAppBar( + title = { Text(stringResource(Res.string.new_subgroup)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) }, floatingActionButton = { ExtendedFloatingActionButton( @@ -334,7 +339,8 @@ private fun SelectSubgroupAdminsScreenPreview() { activeIdentityStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, - onNavigateToRoute = {} + onNavigateToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt index b458efb5..fc53f059 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt @@ -10,13 +10,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -55,6 +53,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -92,18 +91,7 @@ fun ShareProfileScreen( text = stringResource(Res.string.profile) ) }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index f60352fd..8dac4889 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Spellcheck @@ -40,7 +39,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.a_password_protected_key_ncryptsec_is_not import mantra.composeapp.generated.resources.an_npub_is_a_public_key_mantra_needs_the -import mantra.composeapp.generated.resources.back import mantra.composeapp.generated.resources.enter_a_recovery_phrase_or_an_nsec_to_sign_in import mantra.composeapp.generated.resources.next import mantra.composeapp.generated.resources.paste @@ -73,6 +71,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -121,14 +120,7 @@ fun SignInToProfileScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.sign_in)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back) - ) - } - } + navigationIcon = { NavigateBackButton(onNavigateBack) } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index b2a030e1..9bd960f7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -70,7 +70,9 @@ fun SovereignWalletStartupScreen( } is ListWalletState.Error -> { ImplementationPendingScreen( - "Failed to load wallet data", + text = "Failed to load wallet data", + // Drawn inside the startup gate, which is the root of the stack. + onNavigateBack = null, ) } is ListWalletState.Success -> { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt index 0d767b3d..bd9bd0af 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ButtonDefaults @@ -21,7 +20,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -65,6 +63,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -124,14 +123,7 @@ fun TranslateChunkScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.translate_chunk)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, bottomBar = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index fef9c9d2..732a9f32 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -10,14 +10,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.PlaylistAdd import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -60,6 +58,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -108,14 +107,7 @@ fun TranslationArtifactVersionDetailScreen( topBar = { TopAppBar( title = { Text(translation.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index 1d0b5df7..c4805f6f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -15,12 +15,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -55,6 +53,7 @@ import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -98,14 +97,7 @@ fun TranslationChapterScreen( topBar = { TopAppBar( title = { Text(stringResource(Res.string.chapter_translation)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt index bdef27a3..4efd8f31 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -29,6 +30,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key @@ -46,6 +48,7 @@ import kotlin.time.Clock import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.new_note import mantra.composeapp.generated.resources.next import mantra.composeapp.generated.resources.something_went_wrong_and_we_were_unable_to_3 import mantra.composeapp.generated.resources.the_above_will_be_your_new_note @@ -57,10 +60,11 @@ import mantra.composeapp.generated.resources.what_vibrations_do_you_want_to_send import mantra.composeapp.generated.resources.replying_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun WriteNewNoteScreen( activeUserPublicKey: HexKey, @@ -69,6 +73,7 @@ fun WriteNewNoteScreen( quotedNostrEventId: HexKey?, onNostrEventPublished: () -> Unit, onNavigateToNostrEvent: (HexKey) -> Unit, + onNavigateBack: () -> Unit, nostrRepository: press.mantra.compose.repository.NostrRepository ) { val writeNewNoteViewModel: press.mantra.compose.ui.view.model.WriteNewNoteViewModel = viewModel ( @@ -84,7 +89,17 @@ fun WriteNewNoteScreen( // keyboard covers whatever is being typed into. Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding()) { innerPadding -> + modifier = Modifier.imePadding(), + topBar = { + // "New note" whether it is a reply, a quote or neither: the body says which + // above the field. Back pops the route and drops the draft, which is what + // the platform's own back does on this screen already. + TopAppBar( + title = { Text(stringResource(Res.string.new_note)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) .readableContent() @@ -522,6 +537,7 @@ It has survived not only five centuries, but also the leap into electronic types ), onNostrEventPublished = {}, onNavigateToNostrEvent = {}, + onNavigateBack = {}, nostrRepository = press.mantra.compose.repository.NostrRepository.NO_OP_NOSTR_REPOSITORY ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 5cdb1334..9dbcedd5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -500,6 +500,9 @@ fun MantraNavHost( popUpTo(0) } }, + onNavigateBack = { + navController.popBackStack() + }, nostrRepository = databaseNostrRepository, marmotRepository = databaseMarmotRepository, writeSeed = { words, onSeedWritten, onSeedWriteError -> @@ -532,6 +535,9 @@ fun MantraNavHost( navController.navigate( selectMembersRoute ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -547,6 +553,9 @@ fun MantraNavHost( navController.navigate( selectChatRoomTypeRoute ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -561,6 +570,9 @@ fun MantraNavHost( dkgRepository = databaseDkgRepository, onNavigateToRoute = { nextRoute -> navController.navigate(route = nextRoute) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -576,6 +588,9 @@ fun MantraNavHost( dkgRepository = databaseDkgRepository, onNavigateToRoute = { approvalRoute -> navController.navigate(route = approvalRoute) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -644,6 +659,9 @@ fun MantraNavHost( inclusive = true } } + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -666,6 +684,9 @@ fun MantraNavHost( ) ) }, + onNavigateBack = { + navController.popBackStack() + }, nostrRepository = databaseNostrRepository ) } @@ -918,6 +939,11 @@ fun MantraNavHost( navController.navigate( route = actionRoute ) + }, + // A pushed route, unlike the same screen inside the home screen's + // detail pane, so there is something to pop. + onNavigateBack = { + navController.popBackStack() } ) } @@ -941,6 +967,9 @@ fun MantraNavHost( route = popRoute, inclusive = false ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -958,6 +987,9 @@ fun MantraNavHost( route = chatRoomDetailRoute ) }, + onNavigateBack = { + navController.popBackStack() + }, ) } composable { backStackEntry -> @@ -980,6 +1012,11 @@ fun MantraNavHost( inclusive = true ) }, + // Back is to the search, not past it: a member who picked the wrong + // person wants the list again, not the room. + onNavigateBack = { + navController.popBackStack() + }, ) } composable { backStackEntry -> @@ -1008,6 +1045,9 @@ fun MantraNavHost( navController.navigate( route = actionRoute ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1034,6 +1074,9 @@ fun MantraNavHost( navController.navigate( route = actionRoute ) + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1054,6 +1097,9 @@ fun MantraNavHost( inclusive = true } } + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1074,6 +1120,9 @@ fun MantraNavHost( inclusive = true } } + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1094,6 +1143,9 @@ fun MantraNavHost( inclusive = true } } + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1115,6 +1167,9 @@ fun MantraNavHost( inclusive = true } } + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1198,6 +1253,9 @@ fun MantraNavHost( inclusive = true } } + }, + onNavigateBack = { + navController.popBackStack() } ) } @@ -1485,8 +1543,23 @@ fun MantraNavHost( } composable { backStackEntry -> val route: ImplementationPendingRoute = backStackEntry.toRoute() + + // Pushed from a "learn more" or an "edit profile", and also where the + // navigation observer above lands with `popUpTo(0)` on an error. Whether + // there is anything under this entry is what decides the back button, and + // it is remembered because the answer is read again while the screen is + // leaving, when the stack has already moved on. + val hasSomethingToReturnTo = remember { + navController.previousBackStackEntry != null + } + ImplementationPendingScreen( - route.name + text = route.name, + onNavigateBack = if (hasSomethingToReturnTo) { + { navController.popBackStack() } + } else { + null + } ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/NavigateBackButton.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/NavigateBackButton.kt new file mode 100644 index 00000000..4f43fcf2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/NavigateBackButton.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.back +import org.jetbrains.compose.resources.stringResource + +/** + * The button in a pushed screen's leading slot, the one M3 calls the navigation icon. + * + * Before this file, 18 screens drew it and 22 did not, and the 18 spelled it four ways: + * `ArrowBack`, the auto-mirrored `ArrowBack`, `ArrowBackIosNew`, and a `"Back"` literal + * against a `stringResource`. A screen that is reached by pushing a route and has no way + * to pop it is a dead end wherever the platform gives no back gesture -- which is the + * desktop target, where a window has neither a back button nor an edge to swipe from. + * + * Two of the four spellings are decisions this component makes once: + * + * - **The icon is the auto-mirrored one.** "Back" points at the leading edge, and the + * leading edge is on the right in an RTL locale. `Icons.Filled.ArrowBack` is + * deprecated for exactly this reason, and the navigation transitions already mirror + * through `slideIntoContainer`; an arrow that did not would point the wrong way at the + * screen that was sliding the right way. + * - **The description is the catalogue's.** "Back" is what a screen reader says, and it + * is text, so it lives in `strings.xml` like every other word the app says. + * + * `IconButton` reserves the 48dp target itself, so there is no modifier to remember. + * + * Put it in `TopAppBar(navigationIcon = …)`, and only on a screen the user pushed onto + * the stack. A top-level destination has the navigation bar instead, and a screen that + * replaced the whole stack -- onboarding, the loading gate -- has nothing under it to + * return to; a back button there would pop to an empty host. + * + * @param onNavigateBack what popping means for the screen, which is nearly always the + * host's `popBackStack()`. Hoisted rather than read from a controller here so the + * screen stays previewable and testable without one. + */ +@Composable +fun NavigateBackButton(onNavigateBack: () -> Unit) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index 2ad86228..d8c0c463 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Error @@ -22,7 +21,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -68,6 +66,7 @@ import mantra.composeapp.generated.resources.send_message import mantra.composeapp.generated.resources.unfollow import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -101,16 +100,7 @@ fun MetadataEventDetail( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( - navigationIcon = { - IconButton( - onClick = onNavigateBack - ) { - Icon( - Icons.Default.ArrowBackIosNew, - contentDescription = "Navigate back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, title = { Column( modifier = Modifier, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt index 35304a9f..180c2475 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddReaction -import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.ChatBubble import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.MoreVert @@ -57,6 +56,7 @@ import mantra.composeapp.generated.resources.reply_to import press.mantra.compose.ui.composable.widgets.ErrorState import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -87,16 +87,7 @@ fun TextNoteEventDetail( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( - navigationIcon = { - IconButton( - onClick = onNavigateBack - ) { - Icon( - Icons.Default.ArrowBackIosNew, - contentDescription = "Navigate back" - ) - } - }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, title = { Text(stringResource(Res.string.post)) }, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt index 342753a0..5afdce93 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt @@ -226,7 +226,8 @@ class EditGroupCuratedSchemaScreenJvmTest { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {} + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index 137aa291..79f58c1b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -665,7 +665,8 @@ class GroupNostrProfileSectionJvmTest { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, onNavigateToRoute = {}, - onPopBackToRoute = {} + onPopBackToRoute = {}, + onNavigateBack = {}, ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/NavigateBackButtonJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/NavigateBackButtonJvmTest.kt new file mode 100644 index 00000000..2ed6f184 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/NavigateBackButtonJvmTest.kt @@ -0,0 +1,111 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Where the back button is, on the two screens that have to decide. + * + * Every other pushed screen draws `NavigateBackButton` unconditionally, and there is + * nothing to assert about that beyond the compiler: the parameter is not optional. These + * two take a nullable callback because the same composable is reached two ways, and + * only the caller knows which: + * + * - the chat room is a destination on a phone and the home screen's detail pane in an + * expanded window. In the pane the room list is beside it, and a button that popped + * would pop the home screen; + * - the "coming soon" placeholder is pushed from a *learn more* and is also where the + * navigation machine lands, with the stack cleared, on an error. + * + * Read by the description a screen reader would announce, which is also the one thing + * about the button a test can check that the type system does not. + */ +@OptIn(ExperimentalTestApi::class) +class NavigateBackButtonJvmTest { + + @Test + fun `a pushed chat room has a back button, and it pops`() = runDesktopComposeUiTest(400, 900) { + var popped = false + setContent { Chat(onNavigateBack = { popped = true }) } + + onNodeWithContentDescription("Back").assertIsDisplayed().performClick() + assertTrue(popped, "the button should have called the host's pop") + } + + @Test + fun `the same chat room beside its list has none`() = runDesktopComposeUiTest(1000, 900) { + setContent { Chat(onNavigateBack = null) } + + onNodeWithContentDescription("Back").assertDoesNotExist() + } + + @Test + fun `a pushed placeholder has a back button`() = runDesktopComposeUiTest(400, 900) { + setContent { Placeholder(onNavigateBack = {}) } + + onNodeWithContentDescription("Back").assertIsDisplayed() + } + + @Test + fun `a placeholder at the root of the stack has none`() = runDesktopComposeUiTest(400, 900) { + setContent { Placeholder(onNavigateBack = null) } + + onNodeWithContentDescription("Back").assertDoesNotExist() + } + + @Composable + private fun Chat(onNavigateBack: (() -> Unit)?) { + MantraTheme { + ProvideSnackbarHost { + ChatRoomMessagingScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = "b".repeat(64), + relayHint = null, + initialChatRoomMessagingUIState = ChatRoomMessagingUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "b".repeat(64), + userPublicKey = "a".repeat(64), + subject = "Reading group", + description = null, + initialGiftWrapPayloadId = null, + mlsGroupState = null, + ), + ), + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateToRoute = {}, + onNavigateBack = onNavigateBack, + ) + } + } + } + + @Composable + private fun Placeholder(onNavigateBack: (() -> Unit)?) { + MantraTheme { + ProvideSnackbarHost { + ImplementationPendingScreen(text = "Learn more", onNavigateBack = onNavigateBack) + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt index 6b471f77..19d7afd4 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt @@ -104,7 +104,8 @@ class ProposeGroupEventScreenJvmTest { chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {} + onNavigateToRouteAndPopUpInclusive = {}, + onNavigateBack = {}, ) } } From c5c89c8d52f27bf10b4c50b4f528182bbfb113b5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:08:44 +0200 Subject: [PATCH 26/71] feat(groups): a row per kind of signed work on the group's screen, each opening its own The nostr profile, the relay lists, the posts, the curated schemas and the curated entries were five sections of cards on the group's screen, every card with a button under it, which made the screen as long as everything the group had ever said, with its members and its subgroups somewhere underneath. Each section is now a row -- a card with an icon and a chevron, the shape of the signing key row above them -- carrying the one line a member wants at a glance: the group's name and address, how many of the four lists it has agreed, and a count of posts, schemas and entries. The row opens a pushed screen holding what the section held, unchanged: the cards, the broadcast under each signed event, the queue under each schema, and the gated ways into the editors, which sit inside the empty state where there is nothing yet. The paste stays on the group's screen at the end of the block, since it is about the block rather than any one row, and the whole block is still absent in a NIP-17 room for the reason it always was. Five routes, screens, view models and states, each reading only what its screen shows plus whether this device can sign. The broadcast button becomes a widget, since five screens draw it, and inComparableGroups moves to the key extensions beside shortened, with the doc comment that had been orphaned from it. The section tests become a fixture object and six tests: one for the rows -- their order, what each says, which route each opens, their absence in a NIP-17 room -- and one per screen for what the sections' tests used to check. Replayed onto Mantra by docs/curated-to-mantra.md: GroupNostrProfileSectionJvmTest.kt: deleted, as this commit deletes it upstream. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@55664cc7d44b163e2cf11083790d91c695b7c782 --- .../composeResources/values/strings.xml | 16 + .../press/mantra/compose/extensions/Member.kt | 17 + .../ui/composable/ChatRoomDetailScreen.kt | 961 +++--------------- .../composable/GroupCuratedEntriesScreen.kt | 365 +++++++ .../composable/GroupCuratedSchemasScreen.kt | 442 ++++++++ .../ui/composable/GroupNostrProfileScreen.kt | 353 +++++++ .../compose/ui/composable/GroupPostsScreen.kt | 352 +++++++ .../ui/composable/GroupRelaysScreen.kt | 327 ++++++ .../ui/composable/navigation/MantraNavHost.kt | 90 ++ .../routes/GroupCuratedEntriesRoute.kt | 16 + .../routes/GroupCuratedSchemasRoute.kt | 16 + .../routes/GroupNostrProfileRoute.kt | 19 + .../navigation/routes/GroupPostsRoute.kt | 15 + .../navigation/routes/GroupRelaysRoute.kt | 15 + .../widgets/buttons/BroadcastButton.kt | 38 + .../model/GroupCuratedEntriesViewModel.kt | 65 ++ .../model/GroupCuratedSchemasViewModel.kt | 66 ++ .../view/model/GroupNostrProfileViewModel.kt | 71 ++ .../ui/view/model/GroupPostsViewModel.kt | 64 ++ .../ui/view/model/GroupRelaysViewModel.kt | 62 ++ .../ui/view/state/ChatRoomDetailUIState.kt | 39 +- .../view/state/GroupCuratedEntriesUIState.kt | 26 + .../view/state/GroupCuratedSchemasUIState.kt | 31 + .../ui/view/state/GroupNostrProfileUIState.kt | 35 + .../ui/view/state/GroupPostsUIState.kt | 40 + .../ui/view/state/GroupRelaysUIState.kt | 31 + .../GroupCuratedEntriesScreenJvmTest.kt | 133 +++ .../GroupCuratedSchemasScreenJvmTest.kt | 231 +++++ .../GroupNostrProfileScreenJvmTest.kt | 153 +++ .../GroupNostrProfileSectionJvmTest.kt | 678 ------------ .../ui/composable/GroupPostsScreenJvmTest.kt | 167 +++ .../ui/composable/GroupRelaysScreenJvmTest.kt | 139 +++ .../ui/composable/GroupSignedWorkFixtures.kt | 212 ++++ .../composable/GroupSignedWorkRowsJvmTest.kt | 282 +++++ 34 files changed, 4068 insertions(+), 1499 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f74dd05c..9244b7d6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -394,6 +394,8 @@ Open Nostr profile This group hasn't said anything about itself on Nostr yet. + + Not described yet Edit Nostr profile Propose Nostr profile What the group says about itself on Nostr @@ -417,6 +419,8 @@ Edit relays Not set yet No relays in this list + + %1$s of %2$s lists agreed Where the group lives on Nostr The group signs each list, so it takes a quorum. Only the lists you change are proposed. This group has no shared key, so it cannot sign a relay list. Run a shared key ceremony first. @@ -444,6 +448,10 @@ Add post Propose post This group hasn't posted anything yet. + + No posts yet + 1 post + %1$s posts What should the group say? The group signs a post, so it takes a quorum. It goes out in the group's name, not yours. This group has no shared key, so it cannot sign a post. Run a shared key ceremony first. @@ -453,6 +461,10 @@ A reply Curated schemas This group hasn't published a curated schema yet. A schema defines a list anyone can suggest entries to and only the group can curate. + + No schemas yet + 1 schema + %1$s schemas Add schema Edit schema Propose schema @@ -614,6 +626,10 @@ Curated entries This group hasn't accepted anything into a list yet. An entry a quorum signs is shown here, and reaches the list's readers once it is broadcast. + + No entries yet + 1 entry + %1$s entries In %1$s Suggested by %1$s diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt index b3fdf441..070e548a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt @@ -9,6 +9,23 @@ private const val SHORTENED_PUBLIC_KEY_LENGTH = 8 /** A public key cut to something readable, for where no profile has arrived yet. */ fun HexKey.shortened(): String = take(SHORTENED_PUBLIC_KEY_LENGTH) +/** + * A long hex value in groups of eight, which is what makes it readable and what + * lets it wrap at all. + * + * Both halves matter. A 64-character key is compared against another member's + * screen a character at a time, and the eye loses its place in an undivided run + * of hex -- the same reason a fingerprint or an account number is grouped. + * + * And a run of hex has no space in it, so Compose has nowhere to break the line: + * it lays the whole thing out on one and lets it run off the side of the sheet, + * where the end of the key cannot be read at all. The spaces are the break + * opportunities. Nothing is copied from these -- the copy button takes the + * signed event, which is not grouped -- so the display can be shaped for reading + * without a paste losing the value. + */ +fun HexKey.inComparableGroups(): String = chunked(8).joinToString(" ") + /** * What to call the member behind this public key on screen. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index b2148a64..c6475bf2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -1,11 +1,7 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,17 +15,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Autorenew -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.Ballot -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.CellTower import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw @@ -63,6 +55,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -83,16 +76,15 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute -import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute -import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute -import press.mantra.compose.extensions.shortened +import press.mantra.compose.extensions.inComparableGroups import press.mantra.compose.extensions.toFormattedTimeAndDateString import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -113,31 +105,21 @@ import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.nostr_profile import mantra.composeapp.generated.resources.relays import mantra.composeapp.generated.resources.posts -import mantra.composeapp.generated.resources.add_post -import mantra.composeapp.generated.resources.a_reply import mantra.composeapp.generated.resources.curated_entries import mantra.composeapp.generated.resources.curated_schemas -import mantra.composeapp.generated.resources.in_list -import mantra.composeapp.generated.resources.suggested_by -import mantra.composeapp.generated.resources.this_group_has_not_accepted_anything_into_a_list_yet -import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet -import mantra.composeapp.generated.resources.add_schema -import mantra.composeapp.generated.resources.view_suggestions -import mantra.composeapp.generated.resources.fields_colon +import mantra.composeapp.generated.resources.not_described_yet +import mantra.composeapp.generated.resources.n_of_m_relay_lists_agreed +import mantra.composeapp.generated.resources.no_posts_yet +import mantra.composeapp.generated.resources.one_post +import mantra.composeapp.generated.resources.n_posts +import mantra.composeapp.generated.resources.no_schemas_yet +import mantra.composeapp.generated.resources.one_schema +import mantra.composeapp.generated.resources.n_schemas +import mantra.composeapp.generated.resources.no_entries_yet +import mantra.composeapp.generated.resources.one_entry +import mantra.composeapp.generated.resources.n_entries import mantra.composeapp.generated.resources.propose_event -import mantra.composeapp.generated.resources.posted_on -import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet -import mantra.composeapp.generated.resources.edit_relays -import mantra.composeapp.generated.resources.not_set_yet -import mantra.composeapp.generated.resources.no_relays_in_this_list -import mantra.composeapp.generated.resources.relay_set_general -import mantra.composeapp.generated.resources.relay_set_messages -import mantra.composeapp.generated.resources.relay_set_search -import mantra.composeapp.generated.resources.relay_set_blocked import mantra.composeapp.generated.resources.no_name_on_this_profile -import mantra.composeapp.generated.resources.edit_nostr_profile -import mantra.composeapp.generated.resources.signed_by_the_group_on -import mantra.composeapp.generated.resources.this_group_has_not_said_anything_about_itself import mantra.composeapp.generated.resources.parent_group import mantra.composeapp.generated.resources.a_subgroup import mantra.composeapp.generated.resources.created_you_are_not_a_member @@ -149,11 +131,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -182,7 +160,6 @@ import mantra.composeapp.generated.resources.this_room_signs_as import mantra.composeapp.generated.resources.derivation_path import mantra.composeapp.generated.resources.key_ceremony import mantra.composeapp.generated.resources.agreed_on -import mantra.composeapp.generated.resources.broadcast import mantra.composeapp.generated.resources.the_signed_event import mantra.composeapp.generated.resources.copy_the_signed_event import mantra.composeapp.generated.resources.copied_the_signed_event @@ -198,7 +175,7 @@ import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews import kotlin.time.Instant -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun ChatRoomDetailScreen( activeUserPublicKey: HexKey, @@ -256,19 +233,6 @@ fun ChatRoomDetailScreen( ) { var isShowingGroupKeyState by rememberSaveable { mutableStateOf(false) } - // The one way into the broadcast screen, for every signed - // event in the identity block below. - val broadcast: (GroupSignedEvent) -> Unit = { signedEvent -> - onNavigateToRoute.invoke( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - signedEventId = signedEvent.id - ) - ) - } - LazyColumn( modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally, @@ -395,130 +359,81 @@ fun ChatRoomDetailScreen( // and description above are this device's names for it; // this is the group's own, signed by the group. // + // One row per kind of statement, each opening the screen + // that shows it, rather than the statements themselves. + // Five sections of cards, every card with a button under + // it, made this screen as long as everything the group + // had ever said, with its members and its subgroups + // somewhere underneath. What a member opening it wants + // first is the shape of that -- a name and a few counts + // -- not every word of it. The row carries the shape; + // the screen behind it carries the rest, with the same + // broadcasts and the same gated ways in the sections had. + // // Marmot rooms only, and absent rather than empty in a // NIP-17 one. A NIP-17 room's id is not a key it can // sign as, so it has no nostr identity at all -- and a - // section saying it has not described one yet would - // promise something that is never coming. + // row saying it has not described one yet would promise + // something that is never coming. if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { item { - Text( - text = stringResource(Res.string.nostr_profile), - style = MaterialTheme.typography.labelMedium - ) - } - - item { - chatRoomDetailUIState.groupNostrProfile?.let { groupNostrProfile -> - // The card and its broadcast button as one - // item, so the button stays under the event - // it sends however the list is arranged. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupNostrProfileCard( - groupNostrProfile = groupNostrProfile, - publicKey = chatRoomId - ) - - // Behind no gate, here and under every - // other signed event in the block: sending - // what the group has already signed takes - // no share of the key, since the - // signature on it is the group's whoever - // repeats it. Only *making* a statement - // is gated. - BroadcastButton( - onClick = { broadcast(groupNostrProfile.signedEvent) } - ) - } - } ?: Text( - stringResource( - Res.string.this_group_has_not_said_anything_about_itself - ) - ) - } - - // The profile is worth reading either way; proposing - // one is a signature by the group, and - // `proposeSigningBatch` throws for a device holding - // no share of the key. Hidden rather than disabled, - // the way the subgroup entry is. - if (chatRoomDetailUIState.canEditNostrProfile) { - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - EditGroupNostrProfileRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) + SignedWorkRow( + icon = Icons.Default.Badge, + headline = stringResource(Res.string.nostr_profile), + // The name the group gave itself and its + // address where it claims one: the two + // things a reader checks a profile for. + // Named rather than falling back to the + // pubkey, for the reason the card gives. + supporting = chatRoomDetailUIState.groupNostrProfile + ?.let { profile -> + listOfNotNull( + profile.name() + ?: stringResource(Res.string.no_name_on_this_profile), + profile.nip05() + ).joinToString(" · ") } - ) { - Icon( - Icons.Default.Badge, - contentDescription = Decorative - ) - - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.space125 + ?: stringResource(Res.string.not_described_yet), + onOpen = { + onNavigateToRoute.invoke( + GroupNostrProfileRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint ) ) - - Text(stringResource(Res.string.edit_nostr_profile)) } - } + ) } // Directly under the profile, because between them // they are the whole of the group's account of // itself on nostr: who it says it is, and where it - // says it can be found. Same gate, same reasons. + // says it can be found. item { - Text( - text = stringResource(Res.string.relays), - style = MaterialTheme.typography.labelMedium - ) - } - - item { - GroupRelayListsCard( - relayLists = chatRoomDetailUIState.groupRelayLists, - onBroadcast = broadcast - ) - } - - if (chatRoomDetailUIState.canEditNostrProfile) { - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - EditGroupRelaysRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.Dns, - contentDescription = Decorative - ) - - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.space125 + SignedWorkRow( + icon = Icons.Default.Dns, + headline = stringResource(Res.string.relays), + // How many of the four lists the group has + // agreed, counted against the sets rather + // than the entries: the entries come back + // empty in a room that has none of this to + // say, and "0 of 0" would say nothing. + supporting = stringResource( + Res.string.n_of_m_relay_lists_agreed, + chatRoomDetailUIState.groupRelayLists.count { it.isAgreed }, + GroupRelaySet.entries.size + ), + onOpen = { + onNavigateToRoute.invoke( + GroupRelaysRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint ) ) - - Text(stringResource(Res.string.edit_relays)) } - } + ) } // Under the relay lists, because these are what the @@ -528,213 +443,53 @@ fun ChatRoomDetailScreen( // library, which is the group's work rather than its // voice. item { - Text( - text = stringResource(Res.string.posts), - style = MaterialTheme.typography.labelMedium + SignedWorkRow( + icon = Icons.Default.Campaign, + headline = stringResource(Res.string.posts), + supporting = when (val count = chatRoomDetailUIState.groupPosts.size) { + 0 -> stringResource(Res.string.no_posts_yet) + 1 -> stringResource(Res.string.one_post) + else -> stringResource(Res.string.n_posts, count) + }, + onOpen = { + onNavigateToRoute.invoke( + GroupPostsRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } ) } - if (chatRoomDetailUIState.groupPosts.isEmpty()) { - item { - Text( - stringResource( - Res.string.this_group_has_not_posted_anything_yet - ) - ) - } - } else { - items( - items = chatRoomDetailUIState.groupPosts, - key = { post -> post.signedEvent.id } - ) { post -> - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupPostCard( - post = post, - publicKey = chatRoomId, - groupNostrProfile = - chatRoomDetailUIState.groupNostrProfile - ) - - BroadcastButton( - onClick = { broadcast(post.signedEvent) } - ) - } - } - } - - if (chatRoomDetailUIState.canEditNostrProfile) { - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - AddGroupPostRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.Campaign, - contentDescription = Decorative - ) - - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.space125 - ) - ) - - Text(stringResource(Res.string.add_post)) - } - } - } - // Under the posts, because a schema is the other // thing a group publishes for strangers to answer: // a post is the group speaking, a schema is the // group asking -- for entries to a list it will // then curate, by quorum, under the same key. What - // it curated follows, in the section below. + // it curated follows, in the row below. item { - Text( - text = stringResource(Res.string.curated_schemas), - style = MaterialTheme.typography.labelMedium + SignedWorkRow( + icon = Icons.Default.Ballot, + headline = stringResource(Res.string.curated_schemas), + supporting = when (val count = chatRoomDetailUIState.groupCuratedSchemas.size) { + 0 -> stringResource(Res.string.no_schemas_yet) + 1 -> stringResource(Res.string.one_schema) + else -> stringResource(Res.string.n_schemas, count) + }, + onOpen = { + onNavigateToRoute.invoke( + GroupCuratedSchemasRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } ) } - if (chatRoomDetailUIState.groupCuratedSchemas.isEmpty()) { - item { - Text( - stringResource( - Res.string.this_group_has_not_published_a_curated_schema_yet - ) - ) - } - } else { - items( - items = chatRoomDetailUIState.groupCuratedSchemas, - key = { curated -> curated.signedEvent.id } - ) { curated -> - // The card and its queue as one item, so - // the button stays under the schema it is - // about however the list is arranged. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupCuratedSchemaCard( - curatedSchema = curated, - // Into the editor for a member who - // could propose a revision, and a plain - // card for one who could not: the same - // gate as every button in this block, - // applied to the card because each - // schema is edited on its own. - onEdit = if (chatRoomDetailUIState.canEditNostrProfile) { - { - onNavigateToRoute.invoke( - EditGroupCuratedSchemaRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = curated.identifier - ) - ) - } - } else { - null - } - ) - - // Under every schema and behind no - // gate, because the queue is the one - // thing here the group did not write: - // a schema exists to be answered by - // strangers, and reading the answers - // takes no share of the key. Per card - // because each list has its own queue. - // - // Beside the broadcast button rather - // than over it, wrapping where the two - // will not fit on one line: both are - // about this one card and a column of - // buttons under every schema would - // read as a menu. - FlowRow( - horizontalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.itemGap, - Alignment.CenterHorizontally - ) - ) { - TextButton( - onClick = { - onNavigateToRoute.invoke( - CuratedSuggestionListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = curated.identifier - ) - ) - } - ) { - Icon( - Icons.Default.Inbox, - contentDescription = Decorative - ) - - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.space125 - ) - ) - - Text(stringResource(Res.string.view_suggestions)) - } - - BroadcastButton( - onClick = { broadcast(curated.signedEvent) } - ) - } - } - } - } - - if (chatRoomDetailUIState.canEditNostrProfile) { - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - EditGroupCuratedSchemaRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = null - ) - ) - } - ) { - Icon( - Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = Decorative - ) - - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.space125 - ) - ) - - Text(stringResource(Res.string.add_schema)) - } - } - } - // Under the schemas, because these are what a // schema is *for*: the schema asks, strangers // answer on the queue, and these are the answers @@ -744,57 +499,35 @@ fun ChatRoomDetailScreen( // never in the room, and an accepted entry is the // last thing in that chain. item { - Text( - text = stringResource(Res.string.curated_entries), - style = MaterialTheme.typography.labelMedium + SignedWorkRow( + icon = Icons.Default.Checklist, + headline = stringResource(Res.string.curated_entries), + supporting = when (val count = chatRoomDetailUIState.groupCuratedEntries.size) { + 0 -> stringResource(Res.string.no_entries_yet) + 1 -> stringResource(Res.string.one_entry) + else -> stringResource(Res.string.n_entries, count) + }, + onOpen = { + onNavigateToRoute.invoke( + GroupCuratedEntriesRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } ) } - if (chatRoomDetailUIState.groupCuratedEntries.isEmpty()) { - item { - Text( - stringResource( - Res.string.this_group_has_not_accepted_anything_into_a_list_yet - ) - ) - } - } else { - items( - items = chatRoomDetailUIState.groupCuratedEntries, - key = { accepted -> accepted.signedEvent.id } - ) { accepted -> - // The card and its broadcast as one item, - // so the button stays under the entry it - // sends. No gate on either: reading what - // the group decided takes no share of the - // key, and neither does repeating its - // signature to a relay. There is no way - // in to add one here, because an entry - // is accepted from the queue it was - // suggested to, on the schema's card - // above. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupCuratedEntryCard(accepted = accepted) - - BroadcastButton( - onClick = { broadcast(accepted.signedEvent) } - ) - } - } - } - // The untyped way into everything above it. Each - // section has a form that builds one kind of event; - // this takes any of those kinds as JSON, pasted from - // wherever it was made, and it lands in whichever - // section is its kind's once the quorum signs -- - // the same reading, off the same signed events. - // Last in the block because it is about the block - // rather than about any one section of it, and - // behind the same gate for the same reason. + // row opens a screen with a form that builds one + // kind of event; this takes any of those kinds as + // JSON, pasted from wherever it was made, and it + // lands on whichever screen is its kind's once the + // quorum signs -- the same reading, off the same + // signed events. Last in the block because it is + // about the block rather than about any one row of + // it, and behind the same gate for the same reason. if (chatRoomDetailUIState.canEditNostrProfile) { item { TextButton( @@ -1557,419 +1290,39 @@ internal fun GroupKeyStateSheetContent( } /** - * A long hex value in groups of eight, which is what makes it readable and what - * lets it wrap at all. + * One row of the identity block: a kind of statement the group has signed, a + * line summarising it, and the way to the screen where it is read in full. * - * Both halves matter. A 64-character key is compared against another member's - * screen a character at a time, and the eye loses its place in an undivided run - * of hex -- the same reason a fingerprint or an account number is grouped. - * - * And a run of hex has no space in it, so Compose has nowhere to break the line: - * it lays the whole thing out on one and lets it run off the side of the sheet, - * where the end of the key cannot be read at all. The spaces are the break - * opportunities. Nothing is copied from these -- the copy button takes the - * signed event, which is not grouped -- so the display can be shaped for reading - * without a paste losing the value. - */ -/** - * What the group says about itself on nostr, as the group signed it. - * - * A preview rather than a profile screen: the name, the picture, the nostr address - * and the first lines of the group's own account of itself, which is what a member - * opening the group's details came here to check. The whole thing is one signed - * event and the proposal list is where it can be read out in full. - * - * **The picture and the name come from the metadata; the tint comes from the key.** - * [publicKey] is the room's id, which is the pubkey the group signs as, so a group - * with no picture yet still gets the colour it has everywhere else in the app. - * - * The signing date is here because a group's profile is standing state with no - * other clue to its age -- and because the alternative reading, that this arrived - * from a relay a moment ago, is exactly what has not happened. Nothing on this card - * was published by being signed -- see `GroupNostrProfile` -- and the broadcast - * button under it is how it gets to a relay. + * The same shape as the signing key and parent group rows above it -- a card + * with a leading icon and a trailing chevron -- because it is the same kind of + * thing: a fact about the group, opened by tapping. The icons are decorative + * because the headline beside each says what the row is, and the chevron says + * only what every card on this screen already says by being one. */ @Composable -private fun GroupNostrProfileCard( - groupNostrProfile: GroupNostrProfile, - publicKey: HexKey +private fun SignedWorkRow( + icon: ImageVector, + headline: String, + supporting: String, + onOpen: () -> Unit ) { - Card(modifier = Modifier.fillMaxWidth()) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) { ListItem( leadingContent = { - ProfileAvatar( - publicKey = publicKey, - picture = groupNostrProfile.picture(), - name = groupNostrProfile.name() - ) + Icon(icon, contentDescription = Decorative) + }, + trailingContent = { + Icon(Icons.Default.ChevronRight, contentDescription = Decorative) }, headlineContent = { - Text( - // Named rather than falling back to the pubkey: the signing key - // row above already shows that, and showing it twice would say - // the group has a name when it has not. - text = groupNostrProfile.name() - ?: stringResource(Res.string.no_name_on_this_profile), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text(text = headline) }, supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - groupNostrProfile.nip05()?.let { - Text( - text = it, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - groupNostrProfile.about()?.let { - Text( - text = it, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = stringResource( - Res.string.signed_by_the_group_on, - groupNostrProfile.signedAt.toFormattedTimeAndDateString() - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - } -} - -/** - * Where the group has said it lives on nostr, one line per relay list. - * - * One card holding four rows rather than four cards: they are read together -- the - * question is whether the group is reachable, not what any single list says -- and - * four cards of one line each on a screen this long is four dividers to scroll past - * for the same four facts. - * - * **A list the group has never agreed reads "not set yet", and an agreed empty one - * reads "no relays".** Those are different things: one is a group that has not got - * round to it, and the other is a group that decided. Collapsing them would hide a - * deliberate withdrawal behind an oversight. - */ -@Composable -private fun GroupRelayListsCard( - relayLists: List, - onBroadcast: (GroupSignedEvent) -> Unit -) { - Card(modifier = Modifier.fillMaxWidth()) { - relayLists.forEach { list -> - ListItem( - leadingContent = { - Icon(Icons.Default.Dns, contentDescription = Decorative) - }, - headlineContent = { - Text(text = stringResource(list.set.summaryLabel())) - }, - supportingContent = { - Text( - text = when { - !list.isAgreed -> stringResource(Res.string.not_set_yet) - list.relays.isEmpty() -> - stringResource(Res.string.no_relays_in_this_list) - // Hosts rather than URLs: every one of them starts - // `wss://`, so the scheme is the part that never - // distinguishes two entries. - else -> list.relays.joinToString { it.displayUrl() } - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - - // Under the row it sends, and only where there is something signed - // to send: a list the group has never agreed is not an event. Inside - // the card because the four rows share one, so "under the event" has - // to mean under the row -- and at the end, where M3 puts a card's - // actions. An agreed empty list gets one too: a withdrawal is a - // statement, and relays still holding the old list need to hear it. - list.signedEvent?.let { signedEvent -> - Box( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = MaterialTheme.spacing.compactPadding), - contentAlignment = Alignment.CenterEnd - ) { - BroadcastButton(onClick = { onBroadcast(signedEvent) }) - } - } - } - } -} - -/** - * The way into `BroadcastGroupSignedEventScreen`, drawn the same under every - * signed event in the identity block so that it reads as one action wherever it - * sits. The icon is decorative because the label is beside it. - */ -@Composable -private fun BroadcastButton(onClick: () -> Unit) { - TextButton(onClick = onClick) { - Icon(Icons.Default.CellTower, contentDescription = Decorative) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - - Text(stringResource(Res.string.broadcast)) - } -} - -/** The name of a relay list, for the summary on the group's screen. */ -private fun GroupRelaySet.summaryLabel() = when (this) { - GroupRelaySet.General -> Res.string.relay_set_general - GroupRelaySet.Messages -> Res.string.relay_set_messages - GroupRelaySet.Search -> Res.string.relay_set_search - GroupRelaySet.Blocked -> Res.string.relay_set_blocked -} - -/** - * One thing the group said, as the group signed it. - * - * The whole note rather than a preview. A post is short by construction -- kind:1 - * with no long-form arm behind it -- and truncating the one kind of content on this - * screen that exists to be *read* would mean opening something to see a paragraph. - * - * **The byline is the group's**, taken from its profile where it has one and from - * the room's own id where it has not. It is worth spelling out because a post is - * the one thing here a reader might otherwise attribute to whichever member - * proposed it -- and the point of signing it as the group is that they cannot. - * - * A reply is marked as one. Nothing in this app writes replies, so the mark is for - * an event that arrived some other way; drawing one as an announcement would put - * half a conversation on a group's page with no sign that it was half. - */ -@Composable -private fun GroupPostCard( - post: GroupPost, - publicKey: HexKey, - groupNostrProfile: GroupNostrProfile? -) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - ProfileAvatar( - size = MaterialTheme.spacing.space400, - publicKey = publicKey, - picture = groupNostrProfile?.picture(), - name = groupNostrProfile?.name() - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = groupNostrProfile?.name() ?: publicKey.take(16).inComparableGroups(), - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = stringResource( - Res.string.posted_on, - post.postedAt.toFormattedTimeAndDateString() - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (!post.isNewThread()) { - Text( - text = stringResource(Res.string.a_reply), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Text( - text = post.content, - style = MaterialTheme.typography.bodyMedium - ) - } - } -} - -/** - * One list the group curates, as the schema it signed for it. - * - * Named by the list -- the domain where it claims one, else the name -- because - * that is what a suggester sees the list called; the title under it is the label - * the event carries. The fields are listed by label rather than counted, since - * what a reader wants to know about a list is what an entry in it asks for, and - * "13 fields" answers a different question. - * - * The visibility is on the last line beside the date, because who may suggest is - * the one fact on the card a member might act on -- a closed list is one they - * would have to be named on. - * - * [onEdit] null draws a card that cannot be opened. It is the section's gate - * applied per card: a schema is edited on its own, so the way in has to be on it. - */ -@Composable -private fun GroupCuratedSchemaCard( - curatedSchema: GroupCuratedSchema, - onEdit: (() -> Unit)? -) { - val content: @Composable () -> Unit = { - ListItem( - leadingContent = { - Icon(Icons.Default.Ballot, contentDescription = Decorative) - }, - trailingContent = onEdit?.let { - { Icon(Icons.Default.Edit, contentDescription = "Edit schema") } - }, - headlineContent = { Text( - text = curatedSchema.schema.displayName(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - text = curatedSchema.schema.title, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = curatedSchema.schema.description, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = stringResource( - Res.string.fields_colon, - curatedSchema.schema.fields.joinToString { it.labelOrName() } - ), - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = listOfNotNull( - curatedSchema.schema.visibility?.let { stringResource(it.label()) }, - stringResource( - Res.string.signed_by_the_group_on, - curatedSchema.signedAt.toFormattedTimeAndDateString() - ) - ).joinToString(" · "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - } - - if (onEdit != null) { - Card(modifier = Modifier.fillMaxWidth(), onClick = onEdit) { content() } - } else { - Card(modifier = Modifier.fillMaxWidth()) { content() } - } -} - -/** - * One entry the group accepted into a list it curates, as the list's own word. - * - * The title first, because every list has one; then which list, since a group - * may curate several and an entry says nothing about its list on its face; - * then the glance the queue row gives -- the fields that say what the entry - * *is*, labelled as the schema labels them, and not the links -- and the body - * where the list has one. The last line credits the suggester, when there was - * one, and dates the group's decision, which is the fact a member reading the - * block came for. - * - * Not a way in to anything: the entry is revised from the queue it was - * suggested to, where the suggestion it came from and the "Curated" mark sit - * side by side, and the card's one action is the broadcast under it. - */ -@Composable -private fun GroupCuratedEntryCard(accepted: GroupCuratedEntry) { - val schema = accepted.list.schema - val entry = accepted.entry - - Card(modifier = Modifier.fillMaxWidth()) { - ListItem( - leadingContent = { - Icon( - Icons.Default.CheckCircle, - contentDescription = Decorative, - tint = MaterialTheme.colorScheme.primary - ) - }, - headlineContent = { - Text( - text = accepted.title, + text = supporting, maxLines = 2, overflow = TextOverflow.Ellipsis ) - }, - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - text = stringResource(Res.string.in_list, schema.displayName()), - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - glanceOf(entry, schema)?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - bodyOf(entry, schema)?.let { - Text( - text = it, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = listOfNotNull( - entry.source?.let { stringResource(Res.string.suggested_by, it.pubKey.shortened()) }, - stringResource( - Res.string.signed_by_the_group_on, - accepted.signedAt.toFormattedTimeAndDateString() - ) - ).joinToString(" · "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } } ) } @@ -2025,8 +1378,6 @@ private fun SubgroupCard( } } -private fun String.inComparableGroups(): String = chunked(8).joinToString(" ") - /** One labelled value of a key state, whole and comparable. */ @Composable private fun GroupKeyStateField( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt new file mode 100644 index 00000000..7edd477e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt @@ -0,0 +1,365 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.curated_entries +import mantra.composeapp.generated.resources.in_list +import mantra.composeapp.generated.resources.signed_by_the_group_on +import mantra.composeapp.generated.resources.suggested_by +import mantra.composeapp.generated.resources.this_group_has_not_accepted_anything_into_a_list_yet +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.shortened +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.GroupCuratedEntry +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.GroupCuratedEntriesViewModel +import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState +import kotlin.time.Instant + +/** + * The entries the group has accepted into the lists it curates, newest first. + * + * Pushed from its row on the group's screen, which counts the entries and + * nothing more. This is where they are read: one card per entry with a + * broadcast under it. No gate on either, because reading what the group + * decided takes no share of the key, and neither does repeating its signature + * to a relay. + * + * There is no way in to add one here, because an entry is accepted from the + * queue it was suggested to, which is reached from its schema's card on the + * `GroupCuratedSchemasScreen`. The empty state says as much. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupCuratedEntriesScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState = + GroupCuratedEntriesUIState.Loading, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val groupCuratedEntriesViewModel: GroupCuratedEntriesViewModel = viewModel( + factory = GroupCuratedEntriesViewModel.factory( + chatRoomId = chatRoomId, + initialGroupCuratedEntriesUIState = initialGroupCuratedEntriesUIState, + chatRepository = chatRepository, + ) + ) + + LaunchedEffect(true) { + if (initialGroupCuratedEntriesUIState == GroupCuratedEntriesUIState.Loading) { + groupCuratedEntriesViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.curated_entries), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { padding -> + ScreenStateTransition(groupCuratedEntriesViewModel.groupCuratedEntriesUIState) { uiState -> + when (val state = uiState) { + is GroupCuratedEntriesUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, + // and reading it again with the same one fails the same way. + ErrorState( + modifier = Modifier.padding(padding), + message = state.message, + onRetry = null + ) + } + + GroupCuratedEntriesUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(padding) + .padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + LoadingDataIndicator(fillScreen = false) + } + } + + is GroupCuratedEntriesUIState.Loaded -> { + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (state.groupCuratedEntries.isEmpty()) { + item { + EmptyState( + message = stringResource( + Res.string.this_group_has_not_accepted_anything_into_a_list_yet + ) + ) + } + } else { + items( + items = state.groupCuratedEntries, + key = { accepted -> accepted.signedEvent.id } + ) { accepted -> + // The card and its broadcast as one item, so the + // button stays under the entry it sends however + // the list is arranged. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupCuratedEntryCard(accepted = accepted) + + BroadcastButton( + onClick = { + onNavigateToRoute.invoke( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = accepted.signedEvent.id + ) + ) + } + ) + } + } + } + } + } + } + } + } +} + +/** + * One entry the group accepted into a list it curates, as the list's own word. + * + * The title first, because every list has one; then which list, since a group + * may curate several and an entry says nothing about its list on its face; + * then the glance the queue row gives -- the fields that say what the entry + * *is*, labelled as the schema labels them, and not the links -- and the body + * where the list has one. The last line credits the suggester, when there was + * one, and dates the group's decision, which is the fact a member reading the + * screen came for. + * + * Not a way in to anything: the entry is revised from the queue it was + * suggested to, where the suggestion it came from and the "Curated" mark sit + * side by side, and the card's one action is the broadcast under it. + */ +@Composable +private fun GroupCuratedEntryCard(accepted: GroupCuratedEntry) { + val schema = accepted.list.schema + val entry = accepted.entry + + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + }, + headlineContent = { + Text( + text = accepted.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = stringResource(Res.string.in_list, schema.displayName()), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + glanceOf(entry, schema)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + bodyOf(entry, schema)?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = listOfNotNull( + entry.source?.let { stringResource(Res.string.suggested_by, it.pubKey.shortened()) }, + stringResource( + Res.string.signed_by_the_group_on, + accepted.signedAt.toFormattedTimeAndDateString() + ) + ).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +@ConformancePreviews +@Composable +private fun GroupCuratedEntriesScreenPreview() { + val chatRoomId = "d".repeat(64) + + // Built rather than read out of signed events: the reading checks a real + // signature, and there is no real one here. + val films = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Films worth translating suggestion", + name = "Films worth translating", + description = "Things the group would translate, with a link to each.", + visibility = CuratedVisibility.Public, + fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), + relays = listOf("wss://relay.one.example") + ) + ) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + GroupCuratedEntriesScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialGroupCuratedEntriesUIState = GroupCuratedEntriesUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + groupCuratedEntries = listOf( + GroupCuratedEntry( + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money") + ), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_900) + ), + list = films, + entry = CuratedEntry( + kind = CuratedEntryEvent.CANONICAL_KIND, + id = "e".repeat(64), + pubKey = chatRoomId, + createdAt = 1_700_000_900, + identifier = "magic-money", + title = "Magic Money", + values = mapOf( + "identifier" to listOf("magic-money"), + "title" to listOf("Magic Money"), + ), + source = null + ) + ) + ) + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt new file mode 100644 index 00000000..5d2f74e7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt @@ -0,0 +1,442 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Ballot +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.add_schema +import mantra.composeapp.generated.resources.curated_schemas +import mantra.composeapp.generated.resources.fields_colon +import mantra.composeapp.generated.resources.signed_by_the_group_on +import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet +import mantra.composeapp.generated.resources.view_suggestions +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.GroupCuratedSchemasViewModel +import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState +import kotlin.time.Instant + +/** + * The lists the group curates, as the schemas it signed for them. + * + * Pushed from its row on the group's screen, which counts the schemas and + * nothing more. This is where they are read: one card per list, most recently + * signed first, each with its queue and its broadcast under it, and -- for a + * member holding a share of the key -- the way into the editor, on the card + * for a revision and at the end for a new list. + * + * A schema exists to be read by people who were never in the room, so a member + * without a share reads it like anybody else, and reads its queue, which the + * group did not write. What they cannot do is propose a revision -- neither a + * new schema nor an edit -- since proposing is a signature by the group and + * `proposeSigningBatch` throws for a device holding no share. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun GroupCuratedSchemasScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState = + GroupCuratedSchemasUIState.Loading, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val groupCuratedSchemasViewModel: GroupCuratedSchemasViewModel = viewModel( + factory = GroupCuratedSchemasViewModel.factory( + chatRoomId = chatRoomId, + initialGroupCuratedSchemasUIState = initialGroupCuratedSchemasUIState, + chatRepository = chatRepository, + ) + ) + + LaunchedEffect(true) { + if (initialGroupCuratedSchemasUIState == GroupCuratedSchemasUIState.Loading) { + groupCuratedSchemasViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.curated_schemas), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { padding -> + ScreenStateTransition(groupCuratedSchemasViewModel.groupCuratedSchemasUIState) { uiState -> + when (val state = uiState) { + is GroupCuratedSchemasUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, + // and reading it again with the same one fails the same way. + ErrorState( + modifier = Modifier.padding(padding), + message = state.message, + onRetry = null + ) + } + + GroupCuratedSchemasUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(padding) + .padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + LoadingDataIndicator(fillScreen = false) + } + } + + is GroupCuratedSchemasUIState.Loaded -> { + // The one way into the editor, for a revision and for a new + // list alike: the editor takes the identifier of the schema + // to revise, and null for one that does not exist yet. + val edit: (String?) -> Unit = { identifier -> + onNavigateToRoute.invoke( + EditGroupCuratedSchemaRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier + ) + ) + } + + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (state.groupCuratedSchemas.isEmpty()) { + item { + EmptyState( + message = stringResource( + Res.string.this_group_has_not_published_a_curated_schema_yet + ), + action = if (state.canSign) { + { AddSchemaButton(onClick = { edit(null) }) } + } else { + null + } + ) + } + } else { + items( + items = state.groupCuratedSchemas, + key = { curated -> curated.signedEvent.id } + ) { curated -> + // The card and its queue as one item, so the + // button stays under the schema it is about + // however the list is arranged. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupCuratedSchemaCard( + curatedSchema = curated, + // Into the editor for a member who could + // propose a revision, and a plain card for + // one who could not: the screen's gate, + // applied to the card because each schema + // is edited on its own. + onEdit = if (state.canSign) { + { edit(curated.identifier) } + } else { + null + } + ) + + // Under every schema and behind no gate, + // because the queue is the one thing here + // the group did not write: a schema exists + // to be answered by strangers, and reading + // the answers takes no share of the key. + // Per card because each list has its own + // queue. + // + // Beside the broadcast button rather than + // over it, wrapping where the two will not + // fit on one line: both are about this one + // card and a column of buttons under every + // schema would read as a menu. + FlowRow( + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap, + Alignment.CenterHorizontally + ) + ) { + TextButton( + onClick = { + onNavigateToRoute.invoke( + CuratedSuggestionListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = curated.identifier + ) + ) + } + ) { + Icon(Icons.Default.Inbox, contentDescription = Decorative) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.view_suggestions)) + } + + BroadcastButton( + onClick = { + onNavigateToRoute.invoke( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = curated.signedEvent.id + ) + ) + } + ) + } + } + } + + if (state.canSign) { + item { + AddSchemaButton(onClick = { edit(null) }) + } + } + } + } + } + } + } + } +} + +/** + * One list the group curates, as the schema it signed for it. + * + * Named by the list -- the domain where it claims one, else the name -- because + * that is what a suggester sees the list called; the title under it is the label + * the event carries. The fields are listed by label rather than counted, since + * what a reader wants to know about a list is what an entry in it asks for, and + * "13 fields" answers a different question. + * + * The visibility is on the last line beside the date, because who may suggest is + * the one fact on the card a member might act on -- a closed list is one they + * would have to be named on. + * + * [onEdit] null draws a card that cannot be opened. It is the screen's gate + * applied per card: a schema is edited on its own, so the way in has to be on it. + */ +@Composable +private fun GroupCuratedSchemaCard( + curatedSchema: GroupCuratedSchema, + onEdit: (() -> Unit)? +) { + val content: @Composable () -> Unit = { + ListItem( + leadingContent = { + Icon(Icons.Default.Ballot, contentDescription = Decorative) + }, + trailingContent = onEdit?.let { + { Icon(Icons.Default.Edit, contentDescription = "Edit schema") } + }, + headlineContent = { + Text( + text = curatedSchema.schema.displayName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = curatedSchema.schema.title, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = curatedSchema.schema.description, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource( + Res.string.fields_colon, + curatedSchema.schema.fields.joinToString { it.labelOrName() } + ), + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = listOfNotNull( + curatedSchema.schema.visibility?.let { stringResource(it.label()) }, + stringResource( + Res.string.signed_by_the_group_on, + curatedSchema.signedAt.toFormattedTimeAndDateString() + ) + ).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } + + if (onEdit != null) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onEdit) { content() } + } else { + Card(modifier = Modifier.fillMaxWidth()) { content() } + } +} + +/** + * The way into `EditGroupCuratedSchemaScreen` for a list that does not exist + * yet. The icon is decorative because the label is beside it. + */ +@Composable +private fun AddSchemaButton(onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.add_schema)) + } +} + +@ConformancePreviews +@Composable +private fun GroupCuratedSchemasScreenPreview() { + val chatRoomId = "d".repeat(64) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + GroupCuratedSchemasScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialGroupCuratedSchemasUIState = GroupCuratedSchemasUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + // Built rather than read out of a signed event: the reading + // checks a real signature, and there is no real one here. + groupCuratedSchemas = listOf( + GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Films worth translating suggestion", + name = "Films worth translating", + description = "Things the group would translate, with a link to each.", + visibility = CuratedVisibility.Public, + fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), + relays = listOf("wss://relay.one.example") + ) + ) + ), + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt new file mode 100644 index 00000000..f60b7414 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt @@ -0,0 +1,353 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.edit_nostr_profile +import mantra.composeapp.generated.resources.no_name_on_this_profile +import mantra.composeapp.generated.resources.nostr_profile +import mantra.composeapp.generated.resources.signed_by_the_group_on +import mantra.composeapp.generated.resources.this_group_has_not_said_anything_about_itself +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.GroupNostrProfileViewModel +import press.mantra.compose.ui.view.state.GroupNostrProfileUIState +import kotlin.time.Instant + +/** + * What the group says about itself on nostr, as the group signed it. + * + * Pushed from its row on the group's screen, which names the profile and + * nothing more. This is where it is read: the card, the broadcast under it, + * and -- for a member holding a share of the key -- the way into the editor. + * A group that has said nothing is told so, and offered the editor from the + * same place, since describing itself is the one thing to do about that. + * + * The profile is worth reading either way; proposing one is a signature by the + * group, and `proposeSigningBatch` throws for a device holding no share of the + * key. The editor is hidden rather than disabled, the way every gated way in + * on the group's screen is. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupNostrProfileScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialGroupNostrProfileUIState: GroupNostrProfileUIState = GroupNostrProfileUIState.Loading, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val groupNostrProfileViewModel: GroupNostrProfileViewModel = viewModel( + factory = GroupNostrProfileViewModel.factory( + chatRoomId = chatRoomId, + initialGroupNostrProfileUIState = initialGroupNostrProfileUIState, + chatRepository = chatRepository, + ) + ) + + LaunchedEffect(true) { + if (initialGroupNostrProfileUIState == GroupNostrProfileUIState.Loading) { + groupNostrProfileViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.nostr_profile), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { padding -> + ScreenStateTransition(groupNostrProfileViewModel.groupNostrProfileUIState) { uiState -> + when (val state = uiState) { + is GroupNostrProfileUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, + // and reading it again with the same one fails the same way. + ErrorState( + modifier = Modifier.padding(padding), + message = state.message, + onRetry = null + ) + } + + GroupNostrProfileUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(padding) + .padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + LoadingDataIndicator(fillScreen = false) + } + } + + is GroupNostrProfileUIState.Loaded -> { + val edit: () -> Unit = { + onNavigateToRoute.invoke( + EditGroupNostrProfileRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + state.groupNostrProfile?.let { groupNostrProfile -> + // The card and its broadcast button as one item, + // so the button stays under the event it sends + // however the list is arranged. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupNostrProfileCard( + groupNostrProfile = groupNostrProfile, + publicKey = chatRoomId + ) + + BroadcastButton( + onClick = { + onNavigateToRoute.invoke( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = groupNostrProfile.signedEvent.id + ) + ) + } + ) + } + } ?: EmptyState( + message = stringResource( + Res.string.this_group_has_not_said_anything_about_itself + ), + // The way to fill the absence, where this device + // could: describing the group is a signature by it. + action = if (state.canSign) { + { EditNostrProfileButton(onClick = edit) } + } else { + null + } + ) + } + + if (state.canSign && state.groupNostrProfile != null) { + item { + EditNostrProfileButton(onClick = edit) + } + } + } + } + } + } + } +} + +/** + * What the group says about itself on nostr, as the group signed it. + * + * A preview rather than a profile screen: the name, the picture, the nostr address + * and the first lines of the group's own account of itself, which is what a member + * opening the group's details came here to check. The whole thing is one signed + * event and the proposal list is where it can be read out in full. + * + * **The picture and the name come from the metadata; the tint comes from the key.** + * [publicKey] is the room's id, which is the pubkey the group signs as, so a group + * with no picture yet still gets the colour it has everywhere else in the app. + * + * The signing date is here because a group's profile is standing state with no + * other clue to its age -- and because the alternative reading, that this arrived + * from a relay a moment ago, is exactly what has not happened. Nothing on this card + * was published by being signed -- see `GroupNostrProfile` -- and the broadcast + * button under it is how it gets to a relay. + */ +@Composable +private fun GroupNostrProfileCard( + groupNostrProfile: GroupNostrProfile, + publicKey: HexKey +) { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = publicKey, + picture = groupNostrProfile.picture(), + name = groupNostrProfile.name() + ) + }, + headlineContent = { + Text( + // Named rather than falling back to the pubkey: the signing key + // row on the group's screen already shows that, and showing it + // twice would say the group has a name when it has not. + text = groupNostrProfile.name() + ?: stringResource(Res.string.no_name_on_this_profile), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + groupNostrProfile.nip05()?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + groupNostrProfile.about()?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.signed_by_the_group_on, + groupNostrProfile.signedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +/** The way into `EditGroupNostrProfileScreen`. The icon is decorative because the label is beside it. */ +@Composable +private fun EditNostrProfileButton(onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(Icons.Default.Badge, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.edit_nostr_profile)) + } +} + +@ConformancePreviews +@Composable +private fun GroupNostrProfileScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + GroupNostrProfileScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = "d".repeat(64), + relayHint = null, + initialGroupNostrProfileUIState = GroupNostrProfileUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "d".repeat(64), + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + // Built directly rather than read out of a signed event: the + // reading checks a signature, and there is no real one here. + groupNostrProfile = GroupNostrProfile( + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = "d".repeat(64), + publicKey = "d".repeat(64), + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + metadata = UserMetadata().apply { + displayName = "Translation collective" + about = "A group translating hard books into Sesotho." + nip05 = "group@example.com" + } + ), + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt new file mode 100644 index 00000000..bac3ebd6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt @@ -0,0 +1,352 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_reply +import mantra.composeapp.generated.resources.add_post +import mantra.composeapp.generated.resources.posted_on +import mantra.composeapp.generated.resources.posts +import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.inComparableGroups +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.GroupPostsViewModel +import press.mantra.compose.ui.view.state.GroupPostsUIState +import kotlin.time.Instant + +/** + * What the group has said publicly, as itself, newest first. + * + * Pushed from its row on the group's screen, which counts the posts and nothing + * more. This is where they are read: one card per post with a broadcast under + * it, and -- for a member holding a share of the key -- the way into the + * composer. A group that has said nothing is told so, and offered the composer + * from the same place. + * + * A post is the one thing the group signs that is meant to be *read* by people + * who were never in the room, so a member without a share reads it like + * anybody else and simply cannot propose the next one: proposing is a signature + * by the group, and `proposeSigningBatch` throws for a device holding no share. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupPostsScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialGroupPostsUIState: GroupPostsUIState = GroupPostsUIState.Loading, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val groupPostsViewModel: GroupPostsViewModel = viewModel( + factory = GroupPostsViewModel.factory( + chatRoomId = chatRoomId, + initialGroupPostsUIState = initialGroupPostsUIState, + chatRepository = chatRepository, + ) + ) + + LaunchedEffect(true) { + if (initialGroupPostsUIState == GroupPostsUIState.Loading) { + groupPostsViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.posts), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { padding -> + ScreenStateTransition(groupPostsViewModel.groupPostsUIState) { uiState -> + when (val state = uiState) { + is GroupPostsUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, + // and reading it again with the same one fails the same way. + ErrorState( + modifier = Modifier.padding(padding), + message = state.message, + onRetry = null + ) + } + + GroupPostsUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(padding) + .padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + LoadingDataIndicator(fillScreen = false) + } + } + + is GroupPostsUIState.Loaded -> { + val add: () -> Unit = { + onNavigateToRoute.invoke( + AddGroupPostRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (state.groupPosts.isEmpty()) { + item { + EmptyState( + message = stringResource( + Res.string.this_group_has_not_posted_anything_yet + ), + action = if (state.canSign) { + { AddPostButton(onClick = add) } + } else { + null + } + ) + } + } else { + items( + items = state.groupPosts, + key = { post -> post.signedEvent.id } + ) { post -> + // The card and its broadcast button as one item, + // so the button stays under the event it sends + // however the list is arranged. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupPostCard( + post = post, + publicKey = chatRoomId, + groupNostrProfile = state.groupNostrProfile + ) + + BroadcastButton( + onClick = { + onNavigateToRoute.invoke( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = post.signedEvent.id + ) + ) + } + ) + } + } + + if (state.canSign) { + item { + AddPostButton(onClick = add) + } + } + } + } + } + } + } + } +} + +/** + * One thing the group said, as the group signed it. + * + * The whole note rather than a preview. A post is short by construction -- kind:1 + * with no long-form arm behind it -- and truncating the one kind of content on this + * screen that exists to be *read* would mean opening something to see a paragraph. + * + * **The byline is the group's**, taken from its profile where it has one and from + * the room's own id where it has not. It is worth spelling out because a post is + * the one thing here a reader might otherwise attribute to whichever member + * proposed it -- and the point of signing it as the group is that they cannot. + * + * A reply is marked as one. Nothing in this app writes replies, so the mark is for + * an event that arrived some other way; drawing one as an announcement would put + * half a conversation on a group's page with no sign that it was half. + */ +@Composable +private fun GroupPostCard( + post: GroupPost, + publicKey: HexKey, + groupNostrProfile: GroupNostrProfile? +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ProfileAvatar( + size = MaterialTheme.spacing.space400, + publicKey = publicKey, + picture = groupNostrProfile?.picture(), + name = groupNostrProfile?.name() + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = groupNostrProfile?.name() ?: publicKey.take(16).inComparableGroups(), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource( + Res.string.posted_on, + post.postedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!post.isNewThread()) { + Text( + text = stringResource(Res.string.a_reply), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = post.content, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +/** The way into `AddGroupPostScreen`. The icon is decorative because the label is beside it. */ +@Composable +private fun AddPostButton(onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(Icons.Default.Campaign, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.add_post)) + } +} + +@ConformancePreviews +@Composable +private fun GroupPostsScreenPreview() { + val chatRoomId = "d".repeat(64) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + GroupPostsScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialGroupPostsUIState = GroupPostsUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + groupPosts = listOf( + GroupPost( + signedEvent = GroupSignedEvent( + id = "b".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = "The first chapter is out. Read it, and tell us where it reads wrong.", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ) + ) + ), + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt new file mode 100644 index 00000000..febfa89a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt @@ -0,0 +1,327 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.edit_relays +import mantra.composeapp.generated.resources.no_relays_in_this_list +import mantra.composeapp.generated.resources.not_set_yet +import mantra.composeapp.generated.resources.relay_set_blocked +import mantra.composeapp.generated.resources.relay_set_general +import mantra.composeapp.generated.resources.relay_set_messages +import mantra.composeapp.generated.resources.relay_set_search +import mantra.composeapp.generated.resources.relays +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.GroupRelaysViewModel +import press.mantra.compose.ui.view.state.GroupRelaysUIState +import kotlin.time.Instant + +/** + * Where the group has said it lives on nostr, one line per relay list. + * + * Pushed from its row on the group's screen, which says how many of the four + * lists the group has agreed and nothing more. This is where they are read, + * with a broadcast under each one the group has signed and -- for a member + * holding a share of the key -- the way into the editor. + * + * There is no empty state to draw: every set gets a row whatever the group has + * said, and the row is where "never agreed" and "agreed empty" are told apart. + * See [GroupRelayListsCard]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupRelaysScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialGroupRelaysUIState: GroupRelaysUIState = GroupRelaysUIState.Loading, + chatRepository: ChatRepository, + onNavigateToRoute: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val groupRelaysViewModel: GroupRelaysViewModel = viewModel( + factory = GroupRelaysViewModel.factory( + chatRoomId = chatRoomId, + initialGroupRelaysUIState = initialGroupRelaysUIState, + chatRepository = chatRepository, + ) + ) + + LaunchedEffect(true) { + if (initialGroupRelaysUIState == GroupRelaysUIState.Loading) { + groupRelaysViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.relays), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { padding -> + ScreenStateTransition(groupRelaysViewModel.groupRelaysUIState) { uiState -> + when (val state = uiState) { + is GroupRelaysUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, + // and reading it again with the same one fails the same way. + ErrorState( + modifier = Modifier.padding(padding), + message = state.message, + onRetry = null + ) + } + + GroupRelaysUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(padding) + .padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + LoadingDataIndicator(fillScreen = false) + } + } + + is GroupRelaysUIState.Loaded -> { + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + GroupRelayListsCard( + relayLists = state.groupRelayLists, + onBroadcast = { signedEvent -> + onNavigateToRoute.invoke( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + signedEventId = signedEvent.id + ) + ) + } + ) + } + + // The lists are worth reading either way; proposing them + // is a signature by the group, and `proposeSigningBatch` + // throws for a device holding no share of the key. Hidden + // rather than disabled, the way every gated way in on the + // group's screen is. + if (state.canSign) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + EditGroupRelaysRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon(Icons.Default.Dns, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.edit_relays)) + } + } + } + } + } + } + } + } +} + +/** + * Where the group has said it lives on nostr, one line per relay list. + * + * One card holding four rows rather than four cards: they are read together -- the + * question is whether the group is reachable, not what any single list says -- and + * four cards of one line each is four dividers to scroll past for the same four + * facts. + * + * **A list the group has never agreed reads "not set yet", and an agreed empty one + * reads "no relays".** Those are different things: one is a group that has not got + * round to it, and the other is a group that decided. Collapsing them would hide a + * deliberate withdrawal behind an oversight. + */ +@Composable +private fun GroupRelayListsCard( + relayLists: List, + onBroadcast: (GroupSignedEvent) -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + relayLists.forEach { list -> + ListItem( + leadingContent = { + Icon(Icons.Default.Dns, contentDescription = Decorative) + }, + headlineContent = { + Text(text = stringResource(list.set.summaryLabel())) + }, + supportingContent = { + Text( + text = when { + !list.isAgreed -> stringResource(Res.string.not_set_yet) + list.relays.isEmpty() -> + stringResource(Res.string.no_relays_in_this_list) + // Hosts rather than URLs: every one of them starts + // `wss://`, so the scheme is the part that never + // distinguishes two entries. + else -> list.relays.joinToString { it.displayUrl() } + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + + // Under the row it sends, and only where there is something signed + // to send: a list the group has never agreed is not an event. Inside + // the card because the four rows share one, so "under the event" has + // to mean under the row -- and at the end, where M3 puts a card's + // actions. An agreed empty list gets one too: a withdrawal is a + // statement, and relays still holding the old list need to hear it. + list.signedEvent?.let { signedEvent -> + Box( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.compactPadding), + contentAlignment = Alignment.CenterEnd + ) { + BroadcastButton(onClick = { onBroadcast(signedEvent) }) + } + } + } + } +} + +/** The name of a relay list, for the row that summarises it. */ +private fun GroupRelaySet.summaryLabel() = when (this) { + GroupRelaySet.General -> Res.string.relay_set_general + GroupRelaySet.Messages -> Res.string.relay_set_messages + GroupRelaySet.Search -> Res.string.relay_set_search + GroupRelaySet.Blocked -> Res.string.relay_set_blocked +} + +@ConformancePreviews +@Composable +private fun GroupRelaysScreenPreview() { + val chatRoomId = "d".repeat(64) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + GroupRelaysScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialGroupRelaysUIState = GroupRelaysUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + // One agreed list and three the group has not got round to, + // so the two things a row can say are both on the screen. + groupRelayLists = GroupRelaySet.entries.map { set -> + if (set == GroupRelaySet.General) { + GroupRelayList( + set = set, + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + relays = listOf( + GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example")) + ) + ) + } else { + GroupRelayList(set = set, signedEvent = null, relays = emptyList()) + } + }, + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9dbcedd5..532f3200 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -48,6 +48,11 @@ import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen import press.mantra.compose.ui.composable.CuratedSuggestionListScreen import press.mantra.compose.ui.composable.ProposeGroupEventScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen +import press.mantra.compose.ui.composable.GroupNostrProfileScreen +import press.mantra.compose.ui.composable.GroupRelaysScreen +import press.mantra.compose.ui.composable.GroupPostsScreen +import press.mantra.compose.ui.composable.GroupCuratedSchemasScreen +import press.mantra.compose.ui.composable.GroupCuratedEntriesScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen import press.mantra.compose.ui.composable.KeyPackageManagementScreen @@ -140,6 +145,11 @@ import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSigned import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute @@ -1080,6 +1090,86 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + GroupNostrProfileScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate(route = actionRoute) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + GroupRelaysScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate(route = actionRoute) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + GroupPostsScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate(route = actionRoute) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + GroupCuratedSchemasScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate(route = actionRoute) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + GroupCuratedEntriesScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate(route = actionRoute) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt new file mode 100644 index 00000000..e70359a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt @@ -0,0 +1,16 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The entries the group has accepted into the lists it curates, newest first, + * with a broadcast under each. Nothing is added from here: an entry is accepted + * from the queue it was suggested to, which is reached from its schema's card + * on the [GroupCuratedSchemasRoute] screen. + */ +@Serializable +data class GroupCuratedEntriesRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt new file mode 100644 index 00000000..ab698bf9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt @@ -0,0 +1,16 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The lists the group curates, as the schemas it signed for them, one card per + * list with its queue and its broadcast under it. Each card is the way into + * that schema's editor for a member holding a share of the key, and the screen + * ends with the way to add one. + */ +@Serializable +data class GroupCuratedSchemasRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt new file mode 100644 index 00000000..c88e74a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * What the group says about itself on nostr, as the group signed it. + * + * The group's screen names the profile in a row; this is where it is read, with + * the broadcast under it and, for a member holding a share of the key, the way + * into the editor. One of the five screens the group's signed work is split + * across -- see [GroupRelaysRoute], [GroupPostsRoute], [GroupCuratedSchemasRoute] + * and [GroupCuratedEntriesRoute] -- each pushed from its row on the group's screen. + */ +@Serializable +data class GroupNostrProfileRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt new file mode 100644 index 00000000..da9a5623 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt @@ -0,0 +1,15 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * What the group has said publicly, as itself, newest first, with a broadcast + * under each post and the way into the composer for a member holding a share + * of the key. + */ +@Serializable +data class GroupPostsRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt new file mode 100644 index 00000000..dfa88279 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt @@ -0,0 +1,15 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Where the group has said it lives on nostr: its four relay lists, as the + * group signed them, with a broadcast under each one it has agreed and the way + * into the editor for a member holding a share of the key. + */ +@Serializable +data class GroupRelaysRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt new file mode 100644 index 00000000..3912ff8b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt @@ -0,0 +1,38 @@ +package press.mantra.compose.ui.composable.widgets.buttons + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CellTower +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.broadcast +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.spacing + +/** + * The way into `BroadcastGroupSignedEventScreen`, drawn the same under every + * event the group signed -- its profile, each relay list, each post, each schema + * and each entry -- so that it reads as one action on whichever screen it sits. + * + * Behind no gate anywhere it appears: sending what the group has already signed + * takes no share of the key, since the signature on it is the group's whoever + * repeats it. Only *making* a statement is gated, and that is the caller's + * concern. The icon is decorative because the label is beside it. + */ +@Composable +fun BroadcastButton(onClick: () -> Unit) { + TextButton(onClick = onClick) { + Icon(Icons.Default.CellTower, contentDescription = Decorative) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + + Text(stringResource(Res.string.broadcast)) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt new file mode 100644 index 00000000..b9412b92 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt @@ -0,0 +1,65 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState + +/** + * The entries the group has accepted into its lists, read once for the screen + * that shows them. One read rather than a watch, for the reason + * `GroupNostrProfileViewModel` gives. Nothing here asks whether this device + * can sign, because nothing on the screen would change if it could. + */ +class GroupCuratedEntriesViewModel( + val chatRoomId: String, + initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var groupCuratedEntriesUIState: GroupCuratedEntriesUIState by mutableStateOf( + initialGroupCuratedEntriesUIState + ) + private set + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + groupCuratedEntriesUIState = if (localChatRoom == null) { + GroupCuratedEntriesUIState.Error("Couldn't find the group") + } else { + GroupCuratedEntriesUIState.Loaded( + localChatRoom = localChatRoom, + groupCuratedEntries = chatRepository.groupCuratedEntries(chatRoomId), + ) + } + } + } + + companion object { + fun factory( + chatRoomId: String, + initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState = + GroupCuratedEntriesUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + GroupCuratedEntriesViewModel( + chatRoomId = chatRoomId, + initialGroupCuratedEntriesUIState = initialGroupCuratedEntriesUIState, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt new file mode 100644 index 00000000..e3988d9b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt @@ -0,0 +1,66 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState + +/** + * The schemas of the lists the group curates, read once for the screen that + * shows them. One read rather than a watch, for the reason + * `GroupNostrProfileViewModel` gives. + */ +class GroupCuratedSchemasViewModel( + val chatRoomId: String, + initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var groupCuratedSchemasUIState: GroupCuratedSchemasUIState by mutableStateOf( + initialGroupCuratedSchemasUIState + ) + private set + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + groupCuratedSchemasUIState = if (localChatRoom == null) { + GroupCuratedSchemasUIState.Error("Couldn't find the group") + } else { + GroupCuratedSchemasUIState.Loaded( + localChatRoom = localChatRoom, + groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId), + canSign = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId), + ) + } + } + } + + companion object { + fun factory( + chatRoomId: String, + initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState = + GroupCuratedSchemasUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + GroupCuratedSchemasViewModel( + chatRoomId = chatRoomId, + initialGroupCuratedSchemasUIState = initialGroupCuratedSchemasUIState, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt new file mode 100644 index 00000000..2d7b4315 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt @@ -0,0 +1,71 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.GroupNostrProfileUIState + +/** + * The group's nostr profile, read once for the screen that shows it. + * + * One read rather than a watch, the way the group's screen reads it: a profile + * changes when a quorum signs a new one, which lands as a group event this + * device processes, and the screen is re-entered -- and re-read -- on the way + * back from the signing session that made it. + */ +class GroupNostrProfileViewModel( + val chatRoomId: String, + initialGroupNostrProfileUIState: GroupNostrProfileUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var groupNostrProfileUIState: GroupNostrProfileUIState by mutableStateOf( + initialGroupNostrProfileUIState + ) + private set + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + groupNostrProfileUIState = if (localChatRoom == null) { + GroupNostrProfileUIState.Error("Couldn't find the group") + } else { + GroupNostrProfileUIState.Loaded( + localChatRoom = localChatRoom, + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + // A NIP-17 room is excluded by the first half: it has no key + // of its own to sign as, so nothing to hold a share of. + canSign = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId), + ) + } + } + } + + companion object { + fun factory( + chatRoomId: String, + initialGroupNostrProfileUIState: GroupNostrProfileUIState = + GroupNostrProfileUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + GroupNostrProfileViewModel( + chatRoomId = chatRoomId, + initialGroupNostrProfileUIState = initialGroupNostrProfileUIState, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt new file mode 100644 index 00000000..20fae7f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt @@ -0,0 +1,64 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.GroupPostsUIState + +/** + * The group's posts, read once for the screen that shows them, with the + * profile that bylines them. One read rather than a watch, for the reason + * `GroupNostrProfileViewModel` gives. + */ +class GroupPostsViewModel( + val chatRoomId: String, + initialGroupPostsUIState: GroupPostsUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var groupPostsUIState: GroupPostsUIState by mutableStateOf(initialGroupPostsUIState) + private set + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + groupPostsUIState = if (localChatRoom == null) { + GroupPostsUIState.Error("Couldn't find the group") + } else { + GroupPostsUIState.Loaded( + localChatRoom = localChatRoom, + groupPosts = chatRepository.groupPosts(chatRoomId), + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + canSign = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId), + ) + } + } + } + + companion object { + fun factory( + chatRoomId: String, + initialGroupPostsUIState: GroupPostsUIState = GroupPostsUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + GroupPostsViewModel( + chatRoomId = chatRoomId, + initialGroupPostsUIState = initialGroupPostsUIState, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt new file mode 100644 index 00000000..8c6c4152 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt @@ -0,0 +1,62 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.GroupRelaysUIState + +/** + * The group's relay lists, read once for the screen that shows them. One read + * rather than a watch, for the reason `GroupNostrProfileViewModel` gives. + */ +class GroupRelaysViewModel( + val chatRoomId: String, + initialGroupRelaysUIState: GroupRelaysUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var groupRelaysUIState: GroupRelaysUIState by mutableStateOf(initialGroupRelaysUIState) + private set + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + groupRelaysUIState = if (localChatRoom == null) { + GroupRelaysUIState.Error("Couldn't find the group") + } else { + GroupRelaysUIState.Loaded( + localChatRoom = localChatRoom, + groupRelayLists = chatRepository.groupRelayLists(chatRoomId), + canSign = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId), + ) + } + } + } + + companion object { + fun factory( + chatRoomId: String, + initialGroupRelaysUIState: GroupRelaysUIState = GroupRelaysUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + GroupRelaysViewModel( + chatRoomId = chatRoomId, + initialGroupRelaysUIState = initialGroupRelaysUIState, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index efc4e4af..7222beae 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -59,11 +59,12 @@ sealed interface ChatRoomDetailUIState { * The kind:0 the group signed about itself, or null while it has signed * none. * - * Sits under the room's identity and above its work, because that is what - * it is: the name and the picture the rest of nostr sees on the key the - * signing key row names. Null is an ordinary state and the screen says so - * -- a group has an identity from the moment it has a key and describes it - * whenever somebody gets round to it. + * Its row sits under the room's identity and above its work, because that + * is what it is: the name and the picture the rest of nostr sees on the + * key the signing key row names. The row shows the name and the address; + * the profile itself is read on `GroupNostrProfileScreen`. Null is an + * ordinary state and the row says so -- a group has an identity from the + * moment it has a key and describes it whenever somebody gets round to it. */ val groupNostrProfile: GroupNostrProfile? = null, /** @@ -71,7 +72,8 @@ sealed interface ChatRoomDetailUIState { * * Beside the profile because they are the same kind of fact -- the group's * own account of itself, signed by the group -- and read in the same breath - * by anybody checking whether the group is reachable. + * by anybody checking whether the group is reachable. The row counts how + * many the group has agreed; the lists are read on `GroupRelaysScreen`. * * Empty only in a room that has none of this to say. Every other case has * four entries, some of them unsigned; see `ChatRepository.groupRelayLists`. @@ -82,8 +84,9 @@ sealed interface ChatRoomDetailUIState { * * Under the relay lists because it is the thing they are *for*: the profile * says who the group is, the relay lists say where to find it, and these - * are what a reader would find there. Empty in a group that has said - * nothing, which is every group until somebody proposes one. + * are what a reader would find there. The row counts them; they are read + * on `GroupPostsScreen`. Empty in a group that has said nothing, which is + * every group until somebody proposes one. */ val groupPosts: List = emptyList(), /** @@ -92,7 +95,8 @@ sealed interface ChatRoomDetailUIState { * * Under the posts because a schema is the other thing a group publishes * for strangers to answer: a post is the group speaking, a schema is the - * group asking -- for entries to a list it will then curate. Empty in a + * group asking -- for entries to a list it will then curate. The row + * counts them; they are read on `GroupCuratedSchemasScreen`. Empty in a * group that curates nothing, which is every group until somebody * proposes a schema. */ @@ -103,18 +107,21 @@ sealed interface ChatRoomDetailUIState { * * Under the schemas because they are what a schema is *for*: the schema * asks, strangers suggest, and these are what the group decided to - * stand behind. Empty in a group that has accepted nothing, which is - * every group until a quorum signs an entry. + * stand behind. The row counts them; they are read on + * `GroupCuratedEntriesScreen`. Empty in a group that has accepted + * nothing, which is every group until a quorum signs an entry. */ val groupCuratedEntries: List = emptyList(), /** - * Whether this device could sign a profile for the group. + * Whether this device could sign an event for the group, which on this + * screen gates the paste at the end of the identity block. Each of the + * five screens the rows open reads the same capability for its own gate. * * The same capability [canAddSubgroup] wants, and wanted for the same - * reason: writing the group's profile is a signature by the group, and - * `FrostSigningManager.proposeSigningBatch` throws for a device holding no - * share of the key. It is not about permission -- any member with a share - * may propose -- and the quorum is what decides. + * reason: proposing anything in the group's name is a signature by the + * group, and `FrostSigningManager.proposeSigningBatch` throws for a + * device holding no share of the key. It is not about permission -- any + * member with a share may propose -- and the quorum is what decides. * * A NIP-17 room is excluded by the same condition, since it has no key of * its own to sign as and so no nostr identity to describe. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt new file mode 100644 index 00000000..88622196 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedEntry + +sealed interface GroupCuratedEntriesUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * The entries this group has accepted into the lists it curates, most + * recently signed first. Empty in a group that has accepted nothing, + * which is every group until a quorum signs an entry. + * + * No capability beside it, because nothing on the screen needs one: + * reading what the group decided takes no share of the key, and + * neither does repeating its signature to a relay. + */ + val groupCuratedEntries: List = emptyList(), + ): GroupCuratedEntriesUIState + + data class Error( + val message: String + ): GroupCuratedEntriesUIState + + data object Loading: GroupCuratedEntriesUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt new file mode 100644 index 00000000..079c8c33 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt @@ -0,0 +1,31 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedSchema + +sealed interface GroupCuratedSchemasUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * The lists this group curates, as the schemas it signed for them, most + * recently signed first. Empty in a group that curates nothing, which + * is every group until somebody proposes a schema. + */ + val groupCuratedSchemas: List = emptyList(), + /** + * Whether this device holds a share of the group's key. + * + * False leaves the schemas readable -- and their queues, which take no + * share to read -- and hides both ways into the editor, the card's and + * the button's, for the reason `GroupNostrProfileUIState.Loaded.canSign` + * gives. + */ + val canSign: Boolean = false, + ): GroupCuratedSchemasUIState + + data class Error( + val message: String + ): GroupCuratedSchemasUIState + + data object Loading: GroupCuratedSchemasUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt new file mode 100644 index 00000000..8facb25f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt @@ -0,0 +1,35 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile + +sealed interface GroupNostrProfileUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * The kind:0 the group signed about itself, or null while it has signed + * none. + * + * Null is an ordinary state and the screen says so -- a group has an + * identity from the moment it has a key and describes it whenever + * somebody gets round to it. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the profile readable and hides the way into the editor: + * proposing one is a signature by the group, and + * `FrostSigningManager.proposeSigningBatch` throws for a device holding + * no share. It is not about permission -- any member with a share may + * propose -- and the quorum is what decides. + */ + val canSign: Boolean = false, + ): GroupNostrProfileUIState + + data class Error( + val message: String + ): GroupNostrProfileUIState + + data object Loading: GroupNostrProfileUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt new file mode 100644 index 00000000..e3d4358b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost + +sealed interface GroupPostsUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * What the group has said publicly, as itself, newest first. Empty in + * a group that has said nothing, which is every group until somebody + * proposes a post. + */ + val groupPosts: List = emptyList(), + /** + * The group's profile, for the byline on every post. + * + * Read alongside the posts because a post is the one thing here a + * reader might otherwise attribute to whichever member proposed it, + * and the group's own name and picture are what say it was the group. + * Null where the group has not described itself, and the byline falls + * back to the key. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the posts readable and hides the way into the composer, + * for the reason `GroupNostrProfileUIState.Loaded.canSign` gives. + */ + val canSign: Boolean = false, + ): GroupPostsUIState + + data class Error( + val message: String + ): GroupPostsUIState + + data object Loading: GroupPostsUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt new file mode 100644 index 00000000..48cd1902 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt @@ -0,0 +1,31 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelayList + +sealed interface GroupRelaysUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * Where the group has said it lives on nostr, one entry per relay set. + * + * Empty only in a room that has none of this to say. Every other case + * has four entries, some of them unsigned; see + * `ChatRepository.groupRelayLists`. + */ + val groupRelayLists: List = emptyList(), + /** + * Whether this device holds a share of the group's key. + * + * False leaves the lists readable and hides the way into the editor, + * for the reason `GroupNostrProfileUIState.Loaded.canSign` gives. + */ + val canSign: Boolean = false, + ): GroupRelaysUIState + + data class Error( + val message: String + ): GroupRelaysUIState + + data object Loading: GroupRelaysUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt new file mode 100644 index 00000000..dc50e0b2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt @@ -0,0 +1,133 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.nostr.GroupCuratedEntry +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.entry +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The entries the group has accepted on their own screen: one card per entry, + * most recently signed first, each with its broadcast and nothing else -- + * there is no way in to add one here. + */ +@OptIn(ExperimentalTestApi::class) +class GroupCuratedEntriesScreenJvmTest { + + private val newest = entry("The Rise and Rise of Bitcoin", 1_700_000_900) + private val oldest = entry("Magic Money", 1_700_000_300, suggestedBy = null) + + @Test + fun `the entries are drawn most recently signed first, one card per entry`() = render( + state(entries = listOf(newest, oldest)) + ) { + val first = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val second = onNodeWithText("Magic Money").getBoundsInRoot().top + assertTrue(first < second, "the entries were drawn oldest first") + + // Which list, what the entry is -- labelled as the schema labels it -- + // and who suggested it, where somebody did. + onAllNodesWithText("In Films worth translating").assertCountEquals(2) + onAllNodesWithText("Year: 2014", substring = true).assertCountEquals(2) + onAllNodesWithText("Suggested by eebb74ab · ", substring = true).assertCountEquals(1) + // Dated like every signed statement the group makes, once per entry. + onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(2) + } + + @Test + fun `every accepted entry offers a broadcast under its own card`() { + var opened: Route? = null + + render(state(entries = listOf(newest, oldest)), onNavigateToRoute = { opened = it }) { + // The entry is the one event the group signs whose broadcast the + // queue screen depends on, since that is where the "Curated" mark is + // read back from. + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(2) + + val firstCard = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val secondCard = onNodeWithText("Magic Money").getBoundsInRoot().top + val first = buttons[0].getBoundsInRoot().top + val second = buttons[1].getBoundsInRoot().top + + assertTrue(firstCard < first && first < secondCard, "the first entry's broadcast is not under its card") + assertTrue(secondCard < second, "the second entry's broadcast is not under its card") + + buttons[0].performClick() + } + + assertEquals( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = newest.signedEvent.id + ), + opened + ) + } + + @Test + fun `a group that has accepted nothing says so, and offers no way to add one here`() = render( + state(entries = emptyList()) + ) { + onNodeWithText("This group hasn't accepted anything into a list yet.", substring = true) + .assertIsDisplayed() + + // An entry is accepted from the queue on its schema's card, on the + // schemas screen, so this screen has no button of its own. + onAllNodesWithText("Add entry").assertCountEquals(0) + onAllNodesWithText("Broadcast").assertCountEquals(0) + } + + private fun state(entries: List) = GroupCuratedEntriesUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom(), + groupCuratedEntries = entries + ) + + private fun render( + uiState: GroupCuratedEntriesUIState, + onNavigateToRoute: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + GroupCuratedEntriesScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialGroupCuratedEntriesUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt new file mode 100644 index 00000000..2bb92775 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt @@ -0,0 +1,231 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.schema +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The schemas of the lists the group curates on their own screen: one card per + * list, most recently signed first, each with its queue and its broadcast, and + * two ways into the editor -- the card for a revision, the button for a new + * list -- for a member holding a share. + */ +@OptIn(ExperimentalTestApi::class) +class GroupCuratedSchemasScreenJvmTest { + + private val films = schema("films", "Films worth translating", 1_700_000_900) + private val books = schema("books", "Books worth translating", 1_700_000_000) + + @Test + fun `the schemas are drawn most recently signed first, one card per list`() = render( + state(schemas = listOf(films, books)) + ) { + val newest = onNodeWithText("Films worth translating").getBoundsInRoot().top + val oldest = onNodeWithText("Books worth translating").getBoundsInRoot().top + assertTrue(newest < oldest, "the schemas were drawn oldest first") + + // What an entry asks for, and who may suggest one. + onAllNodesWithText("Fields: Identifier, Title", substring = true).assertCountEquals(2) + onAllNodesWithText("Public · ", substring = true).assertCountEquals(2) + onNodeWithText("Add schema").assertIsDisplayed() + } + + @Test + fun `every schema offers its own queue, under its own card`() { + var opened: Route? = null + + render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) { + // One per list, because each list has its own queue -- and under the + // card it belongs to rather than once for the screen, since a button + // between two cards would say nothing about which it opened. + val buttons = onAllNodesWithText("View suggestions") + buttons.assertCountEquals(2) + + val filmsCard = onNodeWithText("Films worth translating").getBoundsInRoot().top + val booksCard = onNodeWithText("Books worth translating").getBoundsInRoot().top + val first = buttons[0].getBoundsInRoot().top + val second = buttons[1].getBoundsInRoot().top + + assertTrue(filmsCard < first && first < booksCard, "the first queue button is not under its card") + assertTrue(booksCard < second, "the second queue button is not under its card") + + buttons[1].performClick() + } + + assertEquals( + CuratedSuggestionListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + identifier = "books" + ), + opened + ) + } + + @Test + fun `every schema offers a broadcast, beside its queue`() { + var opened: Route? = null + + render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) { + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(2) + + val filmsCard = onNodeWithText("Films worth translating").getBoundsInRoot().top + val booksCard = onNodeWithText("Books worth translating").getBoundsInRoot().top + assertTrue(filmsCard < buttons[0].getBoundsInRoot().top, "the first broadcast is not under its card") + assertTrue(booksCard < buttons[1].getBoundsInRoot().top, "the second broadcast is not under its card") + + buttons[0].performClick() + } + + assertEquals( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = films.signedEvent.id + ), + opened + ) + } + + @Test + fun `a card opens the editor on its own schema, for a member holding a share of the key`() { + var opened: Route? = null + + render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) { + onNodeWithText("Books worth translating").performClick() + } + + assertEquals( + EditGroupCuratedSchemaRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + identifier = "books" + ), + opened + ) + } + + @Test + fun `the button opens the editor on no schema at all`() { + var opened: Route? = null + + render(state(schemas = listOf(films)), onNavigateToRoute = { opened = it }) { + onNodeWithText("Add schema").performClick() + } + + assertEquals( + EditGroupCuratedSchemaRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + identifier = null + ), + opened + ) + } + + @Test + fun `a member holding no share of the key reads the schemas and is offered no editor`() { + var opened: Route? = null + + render(state(schemas = listOf(films), canSign = false), onNavigateToRoute = { opened = it }) { + // A schema exists to be read by people who were never in the room, so + // a member without a share reads it like anybody else and simply + // cannot propose a revision -- neither a new one nor an edit. + onNodeWithText("Films worth translating").assertIsDisplayed() + onNodeWithText("Add schema").assertDoesNotExist() + onNodeWithContentDescription("Edit schema").assertDoesNotExist() + + // The queue is the one thing on the screen the group did not write, + // and reading it takes no share of the key. + onNodeWithText("View suggestions").assertIsDisplayed() + + // A card that cannot be opened goes nowhere when tapped. + onNodeWithText("Films worth translating").performClick() + } + + assertNull(opened, "a card with no editor behind it opened something") + } + + @Test + fun `a group that curates nothing says so, and offers to add a schema`() { + var opened: Route? = null + + render(state(schemas = emptyList()), onNavigateToRoute = { opened = it }) { + onNodeWithText("This group hasn't published a curated schema yet.", substring = true) + .assertIsDisplayed() + onAllNodesWithText("View suggestions").assertCountEquals(0) + onNodeWithText("Add schema").assertIsDisplayed().performClick() + } + + assertEquals( + EditGroupCuratedSchemaRoute(activeUserPublicKey, chatRoomId, relayHint = null, identifier = null), + opened + ) + } + + private fun state( + schemas: List, + canSign: Boolean = true + ) = GroupCuratedSchemasUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom(), + groupCuratedSchemas = schemas, + canSign = canSign + ) + + private fun render( + uiState: GroupCuratedSchemasUIState, + onNavigateToRoute: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + GroupCuratedSchemasScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialGroupCuratedSchemasUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt new file mode 100644 index 00000000..8f94dd16 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt @@ -0,0 +1,153 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.GroupNostrProfileUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The group's nostr profile on its own screen: what is drawn, what the + * broadcast under it sends, and who is offered the editor. + */ +@OptIn(ExperimentalTestApi::class) +class GroupNostrProfileScreenJvmTest { + + @Test + fun `the profile is drawn whole, with its broadcast under it`() { + var opened: Route? = null + + render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) { + onNodeWithText("Translation collective").assertIsDisplayed() + onNodeWithText("group@example.com").assertIsDisplayed() + onNodeWithText("A group translating hard books into Sesotho.").assertIsDisplayed() + onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(1) + + // One, under the card it sends, and it sends the profile's event. + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(1) + val card = onNodeWithText("group@example.com").getBoundsInRoot().top + assertTrue(card < buttons[0].getBoundsInRoot().top, "the broadcast is not under the card") + + buttons[0].performClick() + } + + assertEquals( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = profile.signedEvent.id + ), + opened + ) + } + + @Test + fun `a member holding a share of the key is offered the editor`() { + var opened: Route? = null + + render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) { + onNodeWithText("Edit Nostr profile").assertIsDisplayed().performClick() + } + + assertEquals( + EditGroupNostrProfileRoute(activeUserPublicKey, chatRoomId, relayHint = null), + opened + ) + } + + @Test + fun `a member holding no share of the key reads the profile and is offered no edit`() = render( + state(groupNostrProfile = profile, canSign = false) + ) { + // The profile is worth reading whoever is looking; proposing one needs a + // share, and `proposeSigningBatch` throws without one. The broadcast + // stays: the signature is the group's whoever repeats it. + onNodeWithText("Translation collective").assertIsDisplayed() + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + onAllNodesWithText("Broadcast").assertCountEquals(1) + } + + @Test + fun `a group that has said nothing says so, and offers to say something`() = render( + state(groupNostrProfile = null) + ) { + onNodeWithText("This group hasn't said anything about itself on Nostr yet.") + .assertIsDisplayed() + onNodeWithText("Edit Nostr profile").assertIsDisplayed() + + // Nothing signed, so nothing to send. + onAllNodesWithText("Broadcast").assertCountEquals(0) + } + + @Test + fun `a group that has said nothing offers no editor to a member holding no share`() = render( + state(groupNostrProfile = null, canSign = false) + ) { + onNodeWithText("This group hasn't said anything about itself on Nostr yet.") + .assertIsDisplayed() + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + } + + private fun state( + groupNostrProfile: GroupNostrProfile?, + canSign: Boolean = true + ) = GroupNostrProfileUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom(), + groupNostrProfile = groupNostrProfile, + canSign = canSign + ) + + /** + * The screen at a phone's width. The state is passed in, so the NO_OP + * repository is never asked for anything: `initiate` only runs for a screen + * that arrives loading. + */ + private fun render( + uiState: GroupNostrProfileUIState, + onNavigateToRoute: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + GroupNostrProfileScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialGroupNostrProfileUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt deleted file mode 100644 index 79f58c1b..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ /dev/null @@ -1,678 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupKeyState -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSuggestionRef -import press.mantra.compose.nostr.curated.CuratedVisibility -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.MantraRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.ChatRoomDetailUIState -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * Where the group's nostr profile sits, and when it is there at all. - * - * The section's contents need no test -- a name and a line of prose in a - * `ListItem` -- but two things about it do, and neither is settled by reading the - * screen top to bottom. - * - * **Its place in the order.** It goes under what the room *is* -- the key it signs - * as and whose child it is -- and above what the room has *made*, because a - * profile is part of the identity rather than part of the work. That ordering is - * an intention that a later `item {}` inserted in the wrong place would silently - * undo, and the screen is long enough that nobody would notice. - * - * **Its absence in a NIP-17 room.** A NIP-17 room's id is a conversation, not a - * key it can sign as, so it has no nostr identity and never will. An empty section - * there would promise something that is not coming, which is worse than saying - * nothing. - */ -@OptIn(ExperimentalTestApi::class) -class GroupNostrProfileSectionJvmTest { - - private val chatRoomId = "d4c3b2a1".repeat(8) - - private val profile = GroupNostrProfile( - signedEvent = GroupSignedEvent( - id = "e".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - // Built rather than read out of the event: the reading checks a real - // signature, and this test is about the screen rather than about that. - metadata = UserMetadata().apply { - displayName = "Translation collective" - about = "A group translating hard books into Sesotho." - nip05 = "group@example.com" - } - ) - - @Test - fun `the profile sits under the signing key and above the library`() = render( - state(groupNostrProfile = profile) - ) { - val signingKey = onNodeWithText("Signing key").getBoundsInRoot().top - val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top - val library = onNodeWithText("Library").getBoundsInRoot().top - - assertTrue( - signingKey < nostrProfile, - "the nostr profile section climbed above the signing key row" - ) - assertTrue( - nostrProfile < library, - "the nostr profile section fell below the library" - ) - - onNodeWithText("Translation collective").assertIsDisplayed() - onNodeWithText("group@example.com").assertIsDisplayed() - } - - @Test - fun `the relay lists sit under the profile and above the library`() = render( - state(groupNostrProfile = profile, relayLists = signedRelayLists()) - ) { - val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top - val relays = onNodeWithText("Relays").getBoundsInRoot().top - val library = onNodeWithText("Library").getBoundsInRoot().top - - assertTrue(nostrProfile < relays, "the relay lists climbed above the profile") - assertTrue(relays < library, "the relay lists fell below the library") - - // Every set gets a row whatever the group has said, and the two absences - // read differently: one has never been agreed, the other was agreed empty. - onNodeWithText("General").assertIsDisplayed() - onNodeWithText("relay.one.example").assertIsDisplayed() - onNodeWithText("Messages").assertIsDisplayed() - onNodeWithText("Search").assertIsDisplayed() - onNodeWithText("Blocked").assertIsDisplayed() - // Two of them, because Messages and Search are both unagreed -- and the - // count is the assertion: a row per set, not a row per set the group has - // got round to. - onAllNodesWithText("Not set yet").assertCountEquals(2) - onNodeWithText("No relays in this list").assertIsDisplayed() - onNodeWithText("Edit relays").assertIsDisplayed() - } - - @Test - fun `a member holding no share of the key reads the relays and is offered no edit`() = render( - state( - groupNostrProfile = profile, - relayLists = signedRelayLists(), - canEditNostrProfile = false - ) - ) { - onNodeWithText("General").assertIsDisplayed() - onNodeWithText("relay.one.example").assertIsDisplayed() - onNodeWithText("Edit relays").assertDoesNotExist() - } - - @Test - fun `a member holding no share of the key reads the posts and is offered no composer`() = - render( - state( - groupNostrProfile = profile, - posts = listOf(post("Something the group said", 1_700_000_000)), - canEditNostrProfile = false - ) - ) { - // A post is the one thing here that is meant to be read by people who - // were not in the room, so a member without a share reads it like - // anybody else and simply cannot propose the next one. - onNodeWithText("Something the group said").assertIsDisplayed() - onNodeWithText("Add post").assertDoesNotExist() - } - - @Test - fun `the posts sit under the relay lists and above the library`() = render( - state( - groupNostrProfile = profile, - relayLists = signedRelayLists(), - posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000)) - ) - ) { - val relays = onNodeWithText("Relays").getBoundsInRoot().top - val posts = onNodeWithText("Posts").getBoundsInRoot().top - val library = onNodeWithText("Library").getBoundsInRoot().top - - assertTrue(relays < posts, "the posts climbed above the relay lists") - assertTrue(posts < library, "the posts fell below the library") - - // Newest first, and whole rather than previewed -- a post is the one thing - // on this screen that exists to be read. - val newest = onNodeWithText("The second thing").getBoundsInRoot().top - val oldest = onNodeWithText("The first thing").getBoundsInRoot().top - assertTrue(newest < oldest, "the posts were drawn oldest first") - - // Under the group's name, not the member's. The whole point of signing a - // post as the group is that no member can be blamed for it. - onAllNodesWithText("Translation collective").assertCountEquals(3) - onNodeWithText("Add post").assertIsDisplayed() - } - - @Test - fun `a group that has posted nothing says so, and offers to post`() = render( - state(groupNostrProfile = profile, relayLists = signedRelayLists()) - ) { - onNodeWithText("Posts").assertIsDisplayed() - onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed() - onNodeWithText("Add post").assertIsDisplayed() - } - - @Test - fun `the curated schemas sit under the posts and above the group's work`() = render( - state( - groupNostrProfile = profile, - relayLists = signedRelayLists(), - posts = listOf(post("Something the group said", 1_700_000_000)), - schemas = listOf( - schema("films", "Films worth translating", 1_700_000_900), - schema("books", "Books worth translating", 1_700_000_000) - ) - ) - ) { - val posts = onNodeWithText("Posts").getBoundsInRoot().top - val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top - val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top - - assertTrue(posts < schemas, "the curated schemas climbed above the posts") - assertTrue(schemas < subgroups, "the curated schemas fell below the group's work") - - // Most recently signed first, one card per list. - val newest = onNodeWithText("Films worth translating").getBoundsInRoot().top - val oldest = onNodeWithText("Books worth translating").getBoundsInRoot().top - assertTrue(newest < oldest, "the schemas were drawn oldest first") - - // What an entry asks for, and who may suggest one. - onAllNodesWithText("Fields: Identifier, Title", substring = true).assertCountEquals(2) - onAllNodesWithText("Public · ", substring = true).assertCountEquals(2) - onNodeWithText("Add schema").assertIsDisplayed() - } - - @Test - fun `a group that curates nothing says so, and offers to add a schema`() = render( - state(groupNostrProfile = profile, relayLists = signedRelayLists()) - ) { - onNodeWithText("Curated schemas").assertIsDisplayed() - onNodeWithText("This group hasn't published a curated schema yet.", substring = true) - .assertIsDisplayed() - onNodeWithText("Add schema").assertIsDisplayed() - } - - @Test - fun `a member holding no share of the key reads the schemas and is offered no editor`() = - render( - state( - groupNostrProfile = profile, - schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), - canEditNostrProfile = false - ) - ) { - // A schema exists to be read by people who were never in the room, so - // a member without a share reads it like anybody else and simply - // cannot propose a revision -- neither a new one nor an edit. - onNodeWithText("Films worth translating").assertIsDisplayed() - onNodeWithText("Add schema").assertDoesNotExist() - onNodeWithContentDescription("Edit schema").assertDoesNotExist() - - // The queue is the one thing in the block the group did not write, - // and reading it takes no share of the key. - onNodeWithText("View suggestions").assertIsDisplayed() - } - - @Test - fun `every schema offers its own queue, under its own card`() = render( - state( - groupNostrProfile = profile, - schemas = listOf( - schema("films", "Films worth translating", 1_700_000_900), - schema("books", "Books worth translating", 1_700_000_000) - ) - ) - ) { - // One per list, because each list has its own queue -- and under the - // card it belongs to rather than once for the section, since a button - // between two cards would say nothing about which it opened. - val buttons = onAllNodesWithText("View suggestions") - buttons.assertCountEquals(2) - - val films = onNodeWithText("Films worth translating").getBoundsInRoot() - val books = onNodeWithText("Books worth translating").getBoundsInRoot() - val first = buttons[0].getBoundsInRoot() - val second = buttons[1].getBoundsInRoot() - - assertTrue(films.top < first.top && first.top < books.top, "the first queue button is not under its card") - assertTrue(books.top < second.top, "the second queue button is not under its card") - } - - @Test - fun `the curated entries sit under the schemas and above the group's work`() = render( - state( - groupNostrProfile = profile, - schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), - entries = listOf( - entry("The Rise and Rise of Bitcoin", 1_700_000_900), - entry("Magic Money", 1_700_000_300, suggestedBy = null) - ) - ) - ) { - val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top - val entries = onNodeWithText("Curated entries").getBoundsInRoot().top - val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top - - assertTrue(schemas < entries, "the curated entries climbed above the schemas") - assertTrue(entries < subgroups, "the curated entries fell below the group's work") - - // Most recently signed first, one card per entry. - val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top - val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top - assertTrue(newest < oldest, "the entries were drawn oldest first") - - // Which list, what the entry is -- labelled as the schema labels it -- - // and who suggested it, where somebody did. - onAllNodesWithText("In Films worth translating").assertCountEquals(2) - onAllNodesWithText("Year: 2014", substring = true).assertCountEquals(2) - onAllNodesWithText("Suggested by eebb74ab · ", substring = true).assertCountEquals(1) - // Dated like every signed statement in the block: the profile, the - // schema, and the two entries. - onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(4) - } - - @Test - fun `a group that has accepted nothing says so, and offers no way to add one here`() = render( - state( - groupNostrProfile = profile, - schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)) - ) - ) { - onNodeWithText("Curated entries").assertIsDisplayed() - onNodeWithText("This group hasn't accepted anything into a list yet.", substring = true) - .assertIsDisplayed() - - // An entry is accepted from the queue on the schema's card, not from - // this section, so the section has no button of its own. - onNodeWithText("View suggestions").assertIsDisplayed() - onAllNodesWithText("Add entry").assertCountEquals(0) - } - - @Test - fun `every accepted entry offers a broadcast under its own card, share or no share`() = render( - state( - groupNostrProfile = profile, - schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), - entries = listOf(entry("The Rise and Rise of Bitcoin", 1_700_000_900)), - canEditNostrProfile = false - ) - ) { - // The profile's, the schema's and the entry's: the entry is the one - // event in the block whose broadcast the queue screen depends on, since - // that is where the "Curated" mark is read back from. - val buttons = onAllNodesWithText("Broadcast") - buttons.assertCountEquals(3) - - val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top - val entryCard = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top - val last = buttons[2].getBoundsInRoot().top - - assertTrue(schemaCard < entryCard, "the entry climbed above its schema") - assertTrue(entryCard < last, "the entry's broadcast is not under its card") - } - - @Test - fun `every signed event in the block offers a broadcast, under its own card`() = render( - state( - groupNostrProfile = profile, - relayLists = signedRelayLists(), - posts = listOf(post("Something the group said", 1_700_000_000)), - schemas = listOf(schema("films", "Films worth translating", 1_700_000_900)) - ) - ) { - // One per signed event: the profile, the two relay lists the group has - // agreed -- the withdrawn one included, since a withdrawal is a statement - // relays still holding the old list need to hear -- the post and the - // schema. Not the two lists the group has never agreed, which are not - // events. - val buttons = onAllNodesWithText("Broadcast") - buttons.assertCountEquals(5) - - // Under the card it sends, in the order the block lays them out. The - // profile card is found by its address, since its name is also the - // post's byline. - val profileCard = onNodeWithText("group@example.com").getBoundsInRoot().top - val relays = onNodeWithText("Relays").getBoundsInRoot().top - val general = onNodeWithText("General").getBoundsInRoot().top - val blocked = onNodeWithText("Blocked").getBoundsInRoot().top - val postCard = onNodeWithText("Something the group said").getBoundsInRoot().top - val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top - val tops = (0 until 5).map { buttons[it].getBoundsInRoot().top } - - assertTrue(profileCard < tops[0] && tops[0] < relays, "the profile's broadcast is not under its card") - assertTrue(general < tops[1] && tops[1] < blocked, "the general list's broadcast is not under its row") - assertTrue(blocked < tops[2] && tops[2] < postCard, "the blocked list's broadcast is not under its row") - assertTrue(postCard < tops[3] && tops[3] < schemaCard, "the post's broadcast is not under its card") - assertTrue(schemaCard < tops[4], "the schema's broadcast is not under its card") - } - - @Test - fun `a member holding no share of the key may still broadcast what the group signed`() = render( - state( - groupNostrProfile = profile, - relayLists = signedRelayLists(), - posts = listOf(post("Something the group said", 1_700_000_000)), - canEditNostrProfile = false - ) - ) { - // Sending what is already signed takes no share of the key -- the - // signature is the group's whoever repeats it -- so this is the one - // action in the block, with the queue, that the gate does not cover. - onAllNodesWithText("Broadcast").assertCountEquals(4) - onNodeWithText("Edit Nostr profile").assertDoesNotExist() - } - - @Test - fun `a member holding a share of the key may propose any event the block can show`() = render( - state(groupNostrProfile = profile, relayLists = signedRelayLists()) - ) { - // Last in the identity block: after the schemas and before the divider, - // because it is about the whole block rather than any one section of it. - val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top - val propose = onNodeWithText("Propose event").getBoundsInRoot().top - val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top - - assertTrue(schemas < propose, "the paste entry climbed above the schemas") - assertTrue(propose < subgroups, "the paste entry fell out of the identity block") - } - - @Test - fun `a member holding no share of the key is offered no paste`() = render( - state(groupNostrProfile = profile, canEditNostrProfile = false) - ) { - // The same gate as every button in the block: proposing is a signature by - // the group, and `proposeSigning` throws for a device holding no share. - onNodeWithText("Propose event").assertDoesNotExist() - } - - @Test - fun `a group that has said nothing says so, and offers to say something`() = render( - state(groupNostrProfile = null) - ) { - onNodeWithText("Nostr profile").assertIsDisplayed() - onNodeWithText("This group hasn't said anything about itself on Nostr yet.") - .assertIsDisplayed() - onNodeWithText("Edit Nostr profile").assertIsDisplayed() - } - - @Test - fun `a member holding no share of the key reads the profile and is offered no edit`() = render( - state(groupNostrProfile = profile, canEditNostrProfile = false) - ) { - // The profile is worth reading whoever is looking; proposing one needs a - // share, and `proposeSigningBatch` throws without one. - onNodeWithText("Translation collective").assertIsDisplayed() - onNodeWithText("Edit Nostr profile").assertDoesNotExist() - } - - @Test - fun `a NIP-17 room has no nostr identity and so no section`() = render( - state(groupNostrProfile = null, mlsGroupState = null, canEditNostrProfile = false) - ) { - onNodeWithText("Nostr profile").assertDoesNotExist() - onNodeWithText("This group hasn't said anything about itself on Nostr yet.") - .assertDoesNotExist() - onNodeWithText("Edit Nostr profile").assertDoesNotExist() - - // The relay lists, the posts and the schemas go with it: all four - // describe a nostr identity this room does not have. - onNodeWithText("Relays").assertDoesNotExist() - onNodeWithText("Edit relays").assertDoesNotExist() - onNodeWithText("Posts").assertDoesNotExist() - onNodeWithText("Add post").assertDoesNotExist() - onNodeWithText("Curated schemas").assertDoesNotExist() - onNodeWithText("Add schema").assertDoesNotExist() - onNodeWithText("Propose event").assertDoesNotExist() - onAllNodesWithText("Broadcast").assertCountEquals(0) - - // The rest of the screen is untouched, so the section's absence is an - // absence rather than a screen that failed to draw. - onNodeWithText("Library").assertIsDisplayed() - } - - /** - * Two agreed sets and two absences, which is the only fixture that exercises - * the three things a row can say: a list, "not set yet", and "no relays". - */ - private fun signedRelayLists(): List = GroupRelaySet.entries.map { set -> - when (set) { - GroupRelaySet.General -> GroupRelayList( - set = set, - signedEvent = relaySignedEvent(set), - relays = listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example"))) - ) - // Agreed and empty: the group withdrew its list rather than never - // having had one. - GroupRelaySet.Blocked -> GroupRelayList( - set = set, - signedEvent = relaySignedEvent(set), - relays = emptyList() - ) - else -> GroupRelayList(set = set, signedEvent = null, relays = emptyList()) - } - } - - private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent( - id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = set.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ) - - private fun post(content: String, createdAt: Long) = GroupPost( - signedEvent = GroupSignedEvent( - id = createdAt.toString().padStart(64, 'b'), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = TextNoteEvent.KIND, - tags = emptyArray(), - content = content, - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ) - ) - - /** - * A list the group curates, built rather than read out of a signed event: - * the reading checks a real signature, and this test is about the screen. - */ - private fun schema(identifier: String, name: String, createdAt: Long) = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = createdAt.toString().padStart(64, 'c'), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ), - schema = CuratedSchema( - identifier = identifier, - title = "$name suggestion", - name = name, - description = "Things the group would translate, with a link to each.", - visibility = CuratedVisibility.Public, - fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), - relays = listOf("wss://relay.one.example") - ) - ) - - /** - * An entry the group accepted into the films list, built rather than read - * out of a signed event, for the reason [schema] is. The list's schema has a - * year, so the card has a glance to give. - */ - private fun entry( - title: String, - createdAt: Long, - suggestedBy: String? = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - ): GroupCuratedEntry { - val list = schema("films", "Films worth translating", 1_700_000_000).let { films -> - films.copy( - schema = films.schema.copy( - fields = films.schema.fields + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year") - ) - ) - } - val identifier = title.lowercase().replace(' ', '-') - - return GroupCuratedEntry( - signedEvent = GroupSignedEvent( - id = createdAt.toString().padStart(64, 'd'), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedEntryEvent.CANONICAL_KIND, - tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ), - list = list, - entry = CuratedEntry( - kind = CuratedEntryEvent.CANONICAL_KIND, - id = createdAt.toString().padStart(64, 'd'), - pubKey = chatRoomId, - createdAt = createdAt, - identifier = identifier, - title = title, - values = mapOf( - "identifier" to listOf(identifier), - "title" to listOf(title), - "year" to listOf("2014"), - ), - source = suggestedBy?.let { - CuratedSuggestionRef(id = "a".repeat(64), coordinate = "31888:$it:$identifier", pubKey = it) - } - ) - ) - } - - private fun state( - groupNostrProfile: GroupNostrProfile?, - relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), - posts: List = emptyList(), - schemas: List = emptyList(), - entries: List = emptyList(), - canEditNostrProfile: Boolean = true, - mlsGroupState: String? = "state" - ) = ChatRoomDetailUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - // Deliberately not the profile's name. The room's subject is this - // device's name for it and the profile is the group's own, and a - // test that used one string for both could not tell them apart. - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = mlsGroupState - ) - ), - // A key state, so the signing key row the section has to sit under is on - // the screen to be measured against. Not a real key: nothing here derives. - groupKeyState = GroupKeyState( - chatRoomId = chatRoomId, - dkgSessionId = "c".repeat(64), - thresholdPublicKey = "02".padEnd(66, 'a'), - derivationPath = "m/9420/0/0", - announcedBy = chatRoomId, - announcedAt = Instant.fromEpochSeconds(1_700_000_000) - ), - groupNostrProfile = groupNostrProfile, - groupRelayLists = relayLists, - groupPosts = posts, - groupCuratedSchemas = schemas, - groupCuratedEntries = entries, - canEditNostrProfile = canEditNostrProfile - ) - - /** - * The screen at a phone's width and a window tall enough to compose the whole - * of it, since a `LazyColumn` does not lay out what it would not show. - * - * The state is passed in rather than loaded, so the NO_OP repositories are - * never asked for anything: `initiateChatRoomDetail` only runs for a screen - * that arrives in [ChatRoomDetailUIState.Loading]. - */ - private fun render( - uiState: ChatRoomDetailUIState, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 4000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - ChatRoomDetailScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - initialChatRoomDetailUIState = uiState, - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, - onNavigateToRoute = {}, - onPopBackToRoute = {}, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt new file mode 100644 index 00000000..15a1af7a --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt @@ -0,0 +1,167 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.post +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile +import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.GroupPostsUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The group's posts on their own screen: newest first and whole, under the + * group's name, each with its broadcast, and the composer for a member holding + * a share. + */ +@OptIn(ExperimentalTestApi::class) +class GroupPostsScreenJvmTest { + + @Test + fun `the posts are drawn newest first, under the group's name`() = render( + state(posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000))) + ) { + // Newest first, and whole rather than previewed -- a post is the one thing + // the group signs that exists to be read. + val newest = onNodeWithText("The second thing").getBoundsInRoot().top + val oldest = onNodeWithText("The first thing").getBoundsInRoot().top + assertTrue(newest < oldest, "the posts were drawn oldest first") + + // Under the group's name, not the member's. The whole point of signing a + // post as the group is that no member can be blamed for it. Once per + // post: there is no profile card on this screen to add a third. + onAllNodesWithText("Translation collective").assertCountEquals(2) + onNodeWithText("Add post").assertIsDisplayed() + } + + @Test + fun `a group that has not described itself bylines its posts with the key`() = render( + state(posts = listOf(post("Something the group said", 1_700_000_000)), groupNostrProfile = null) + ) { + onNodeWithText("Something the group said").assertIsDisplayed() + onNodeWithText(chatRoomId.take(16).chunked(8).joinToString(" ")).assertIsDisplayed() + } + + @Test + fun `every post offers a broadcast under its card`() { + var opened: Route? = null + + render( + state(posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000))), + onNavigateToRoute = { opened = it } + ) { + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(2) + + val newest = onNodeWithText("The second thing").getBoundsInRoot().top + val oldest = onNodeWithText("The first thing").getBoundsInRoot().top + val first = buttons[0].getBoundsInRoot().top + val second = buttons[1].getBoundsInRoot().top + + assertTrue(newest < first && first < oldest, "the first post's broadcast is not under its card") + assertTrue(oldest < second, "the second post's broadcast is not under its card") + + buttons[1].performClick() + } + + assertEquals( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = post("The first thing", 1_700_000_000).signedEvent.id + ), + opened + ) + } + + @Test + fun `a group that has posted nothing says so, and offers to post`() { + var opened: Route? = null + + render(state(posts = emptyList()), onNavigateToRoute = { opened = it }) { + onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed() + onAllNodesWithText("Broadcast").assertCountEquals(0) + onNodeWithText("Add post").assertIsDisplayed().performClick() + } + + assertEquals(AddGroupPostRoute(activeUserPublicKey, chatRoomId, relayHint = null), opened) + } + + @Test + fun `a member holding no share of the key reads the posts and is offered no composer`() = render( + state(posts = listOf(post("Something the group said", 1_700_000_000)), canSign = false) + ) { + // A post is the one thing here that is meant to be read by people who + // were not in the room, so a member without a share reads it like + // anybody else and simply cannot propose the next one. + onNodeWithText("Something the group said").assertIsDisplayed() + onNodeWithText("Add post").assertDoesNotExist() + onAllNodesWithText("Broadcast").assertCountEquals(1) + } + + @Test + fun `a group that has posted nothing offers no composer to a member holding no share`() = render( + state(posts = emptyList(), canSign = false) + ) { + onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed() + onNodeWithText("Add post").assertDoesNotExist() + } + + private fun state( + posts: List, + groupNostrProfile: GroupNostrProfile? = profile, + canSign: Boolean = true + ) = GroupPostsUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom(), + groupPosts = posts, + groupNostrProfile = groupNostrProfile, + canSign = canSign + ) + + private fun render( + uiState: GroupPostsUIState, + onNavigateToRoute: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + GroupPostsScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialGroupPostsUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt new file mode 100644 index 00000000..90b42888 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt @@ -0,0 +1,139 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.relaySignedEvent +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.signedRelayLists +import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.GroupRelaysUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The group's relay lists on their own screen: a row per set whatever the + * group has said, a broadcast under each list it has agreed, and the editor + * for a member holding a share. + */ +@OptIn(ExperimentalTestApi::class) +class GroupRelaysScreenJvmTest { + + @Test + fun `every set gets a row, and the two absences read differently`() = render(state()) { + onNodeWithText("General").assertIsDisplayed() + onNodeWithText("relay.one.example").assertIsDisplayed() + onNodeWithText("Messages").assertIsDisplayed() + onNodeWithText("Search").assertIsDisplayed() + onNodeWithText("Blocked").assertIsDisplayed() + // Two of them, because Messages and Search are both unagreed -- and the + // count is the assertion: a row per set, not a row per set the group has + // got round to. The blocked list was agreed empty, which is a decision + // and reads as one. + onAllNodesWithText("Not set yet").assertCountEquals(2) + onNodeWithText("No relays in this list").assertIsDisplayed() + } + + @Test + fun `every agreed list offers a broadcast under its row, the withdrawn one included`() { + var opened: Route? = null + + render(state(), onNavigateToRoute = { opened = it }) { + // The two the group has agreed -- the withdrawn one included, since a + // withdrawal is a statement relays still holding the old list need to + // hear -- and not the two it has never agreed, which are not events. + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(2) + + val general = onNodeWithText("General").getBoundsInRoot().top + val blocked = onNodeWithText("Blocked").getBoundsInRoot().top + val first = buttons[0].getBoundsInRoot().top + val second = buttons[1].getBoundsInRoot().top + + assertTrue(general < first && first < blocked, "the general list's broadcast is not under its row") + assertTrue(blocked < second, "the blocked list's broadcast is not under its row") + + buttons[1].performClick() + } + + assertEquals( + BroadcastGroupSignedEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + signedEventId = relaySignedEvent(GroupRelaySet.Blocked).id + ), + opened + ) + } + + @Test + fun `a member holding a share of the key is offered the editor`() { + var opened: Route? = null + + render(state(), onNavigateToRoute = { opened = it }) { + onNodeWithText("Edit relays").assertIsDisplayed().performClick() + } + + assertEquals(EditGroupRelaysRoute(activeUserPublicKey, chatRoomId, relayHint = null), opened) + } + + @Test + fun `a member holding no share of the key reads the relays and is offered no edit`() = render( + state(canSign = false) + ) { + onNodeWithText("General").assertIsDisplayed() + onNodeWithText("relay.one.example").assertIsDisplayed() + onNodeWithText("Edit relays").assertDoesNotExist() + onAllNodesWithText("Broadcast").assertCountEquals(2) + } + + private fun state(canSign: Boolean = true) = GroupRelaysUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom(), + groupRelayLists = signedRelayLists(), + canSign = canSign + ) + + private fun render( + uiState: GroupRelaysUIState, + onNavigateToRoute: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + GroupRelaysScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialGroupRelaysUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt new file mode 100644 index 00000000..fae7c63a --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt @@ -0,0 +1,212 @@ +package press.mantra.compose.ui.composable + +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedEntry +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSuggestionRef +import press.mantra.compose.nostr.curated.CuratedVisibility +import kotlin.time.Instant + +/** + * One group's signed work, for the six screens that show it: the group's own, + * where a row names each kind, and the five behind those rows. + * + * Everything here is built rather than read out of a signed event. The reading + * checks a real signature, and these tests are about the screens rather than + * about that -- so nothing here verifies, and none of the keys derive. + */ +internal object GroupSignedWorkFixtures { + + val chatRoomId = "d4c3b2a1".repeat(8) + + val activeUserPublicKey = "a".repeat(64) + + val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + /** + * The room, as this device names it. + * + * The subject is deliberately not the profile's name: the room's subject is + * this device's name for it and the profile is the group's own, and a test + * that used one string for both could not tell them apart. + */ + fun localChatRoom(mlsGroupState: String? = "state") = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = activeUserPublicKey, + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = mlsGroupState + ) + ) + + /** + * A key state, so the signing key row the identity block sits under is on + * the group's screen to be measured against. Not a real key: nothing here + * derives. + */ + val groupKeyState = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = "c".repeat(64), + thresholdPublicKey = "02".padEnd(66, 'a'), + derivationPath = "m/9420/0/0", + announcedBy = chatRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + val profile = GroupNostrProfile( + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + metadata = UserMetadata().apply { + displayName = "Translation collective" + about = "A group translating hard books into Sesotho." + nip05 = "group@example.com" + } + ) + + /** + * Two agreed sets and two absences, which is the only fixture that exercises + * the three things a relay row can say: a list, "not set yet", and "no + * relays". + */ + fun signedRelayLists(): List = GroupRelaySet.entries.map { set -> + when (set) { + GroupRelaySet.General -> GroupRelayList( + set = set, + signedEvent = relaySignedEvent(set), + relays = listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example"))) + ) + // Agreed and empty: the group withdrew its list rather than never + // having had one. + GroupRelaySet.Blocked -> GroupRelayList( + set = set, + signedEvent = relaySignedEvent(set), + relays = emptyList() + ) + else -> GroupRelayList(set = set, signedEvent = null, relays = emptyList()) + } + } + + fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent( + id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + fun post(content: String, createdAt: Long) = GroupPost( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'b'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = content, + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ) + ) + + /** A list the group curates, as the schema it signed for it. */ + fun schema(identifier: String, name: String, createdAt: Long) = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'c'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + schema = CuratedSchema( + identifier = identifier, + title = "$name suggestion", + name = name, + description = "Things the group would translate, with a link to each.", + visibility = CuratedVisibility.Public, + fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), + relays = listOf("wss://relay.one.example") + ) + ) + + /** + * An entry the group accepted into the films list. The list's schema has a + * year, so the card has a glance to give. + */ + fun entry( + title: String, + createdAt: Long, + suggestedBy: String? = suggester + ): GroupCuratedEntry { + val list = schema("films", "Films worth translating", 1_700_000_000).let { films -> + films.copy( + schema = films.schema.copy( + fields = films.schema.fields + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year") + ) + ) + } + val identifier = title.lowercase().replace(' ', '-') + + return GroupCuratedEntry( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'd'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + list = list, + entry = CuratedEntry( + kind = CuratedEntryEvent.CANONICAL_KIND, + id = createdAt.toString().padStart(64, 'd'), + pubKey = chatRoomId, + createdAt = createdAt, + identifier = identifier, + title = title, + values = mapOf( + "identifier" to listOf(identifier), + "title" to listOf(title), + "year" to listOf("2014"), + ), + source = suggestedBy?.let { + CuratedSuggestionRef(id = "a".repeat(64), coordinate = "31888:$it:$identifier", pubKey = it) + } + ) + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt new file mode 100644 index 00000000..76368e53 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt @@ -0,0 +1,282 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import press.mantra.compose.nostr.GroupCuratedEntry +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.entry +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.post +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.schema +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.signedRelayLists +import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute +import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ChatRoomDetailUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The five rows on the group's screen that stand for its signed work, and what + * each one opens. + * + * The rows' contents need little testing -- a name and a count in a `ListItem` + * -- but three things about them do, and none is settled by reading the screen + * top to bottom. + * + * **Their place in the order.** They go under what the room *is* -- the key it + * signs as and whose child it is -- and above what the room has *made*, because + * the group's account of itself is part of its identity rather than part of its + * work; and among themselves they read as a chain, from who the group says it is + * to what it has decided. That ordering is an intention a later `item {}` in + * the wrong place would silently undo, and the screen is long enough that + * nobody would notice. + * + * **What they open.** A row is a promise about the screen behind it, and five + * rows that look alike are five chances to wire one to the wrong route. + * + * **Their absence in a NIP-17 room.** A NIP-17 room's id is a conversation, not + * a key it can sign as, so it has no nostr identity and never will. A row there + * would promise something that is not coming, which is worse than saying + * nothing. + */ +@OptIn(ExperimentalTestApi::class) +class GroupSignedWorkRowsJvmTest { + + @Test + fun `the rows sit under the signing key and above the group's work, in the order the block reads`() = + render(state(groupNostrProfile = profile)) { + val signingKey = onNodeWithText("Signing key").getBoundsInRoot().top + val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top + val relays = onNodeWithText("Relays").getBoundsInRoot().top + val posts = onNodeWithText("Posts").getBoundsInRoot().top + val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top + val entries = onNodeWithText("Curated entries").getBoundsInRoot().top + val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top + + assertTrue(signingKey < nostrProfile, "the nostr profile row climbed above the signing key row") + assertTrue(nostrProfile < relays, "the relays row climbed above the profile") + assertTrue(relays < posts, "the posts row climbed above the relays") + assertTrue(posts < schemas, "the curated schemas row climbed above the posts") + assertTrue(schemas < entries, "the curated entries row climbed above the schemas") + assertTrue(entries < subgroups, "the identity block fell below the group's work") + } + + @Test + fun `each row says what its screen holds`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000)), + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)) + ) + ) { + // The group's own name and address, not the room's subject: the row is + // about what the rest of nostr sees. + onNodeWithText("Translation collective · group@example.com").assertIsDisplayed() + // Counted against the four sets, the withdrawn one included: an agreed + // empty list is a decision, and the row counts decisions. + onNodeWithText("2 of 4 lists agreed").assertIsDisplayed() + onNodeWithText("2 posts").assertIsDisplayed() + onNodeWithText("1 schema").assertIsDisplayed() + onNodeWithText("No entries yet").assertIsDisplayed() + } + + @Test + fun `a group that has signed nothing has rows that say so`() = render( + state(groupNostrProfile = null) + ) { + onNodeWithText("Not described yet").assertIsDisplayed() + onNodeWithText("0 of 4 lists agreed").assertIsDisplayed() + onNodeWithText("No posts yet").assertIsDisplayed() + onNodeWithText("No schemas yet").assertIsDisplayed() + onNodeWithText("No entries yet").assertIsDisplayed() + } + + @Test + fun `a profile with no name is not named after the key`() = render( + state( + groupNostrProfile = GroupNostrProfile( + signedEvent = profile.signedEvent, + metadata = UserMetadata() + ) + ) + ) { + // The signing key row already shows the key, and showing it twice would + // say the group has a name when it has not. + onNodeWithText("No name on this profile").assertIsDisplayed() + } + + @Test + fun `each row opens the screen for its section`() { + val expected = mapOf( + "Nostr profile" to GroupNostrProfileRoute(activeUserPublicKey, chatRoomId, relayHint = null), + "Relays" to GroupRelaysRoute(activeUserPublicKey, chatRoomId, relayHint = null), + "Posts" to GroupPostsRoute(activeUserPublicKey, chatRoomId, relayHint = null), + "Curated schemas" to GroupCuratedSchemasRoute(activeUserPublicKey, chatRoomId, relayHint = null), + "Curated entries" to GroupCuratedEntriesRoute(activeUserPublicKey, chatRoomId, relayHint = null), + ) + + expected.forEach { (row, route) -> + var opened: Route? = null + + render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) { + onNodeWithText(row).performClick() + } + + assertEquals(route, opened, "the $row row opened the wrong screen") + } + } + + @Test + fun `the contents stay off the group's screen`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + posts = listOf(post("Something the group said", 1_700_000_000)), + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), + entries = listOf(entry("The Rise and Rise of Bitcoin", 1_700_000_900)) + ) + ) { + // The rows summarise; the cards, their broadcasts and the gated ways in + // are on the screens behind them. A card that stayed here would be the + // old section under a new name. + onNodeWithText("A group translating hard books into Sesotho.").assertDoesNotExist() + onNodeWithText("relay.one.example").assertDoesNotExist() + onNodeWithText("Something the group said").assertDoesNotExist() + onNodeWithText("The Rise and Rise of Bitcoin").assertDoesNotExist() + onAllNodesWithText("Broadcast").assertCountEquals(0) + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + onNodeWithText("Edit relays").assertDoesNotExist() + onNodeWithText("Add post").assertDoesNotExist() + onNodeWithText("Add schema").assertDoesNotExist() + onNodeWithText("View suggestions").assertDoesNotExist() + } + + @Test + fun `a member holding a share of the key may propose any event the rows can open`() = render( + state(groupNostrProfile = profile) + ) { + // Last in the identity block: after the rows and before the divider, + // because it is about the whole block rather than any one row of it. + val entries = onNodeWithText("Curated entries").getBoundsInRoot().top + val propose = onNodeWithText("Propose event").getBoundsInRoot().top + val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top + + assertTrue(entries < propose, "the paste entry climbed above the rows") + assertTrue(propose < subgroups, "the paste entry fell out of the identity block") + } + + @Test + fun `a member holding no share of the key is offered no paste`() = render( + state(groupNostrProfile = profile, canEditNostrProfile = false) + ) { + // The same gate as every way in behind the rows: proposing is a signature + // by the group, and `proposeSigning` throws for a device holding no share. + // The rows themselves stay, because reading takes no share. + onNodeWithText("Propose event").assertDoesNotExist() + onNodeWithText("Nostr profile").assertIsDisplayed() + onNodeWithText("Curated entries").assertIsDisplayed() + } + + @Test + fun `a NIP-17 room has no nostr identity and so no rows`() = render( + state(groupNostrProfile = null, mlsGroupState = null, canEditNostrProfile = false) + ) { + onNodeWithText("Nostr profile").assertDoesNotExist() + onNodeWithText("Not described yet").assertDoesNotExist() + + // The other four go with it: all five describe a nostr identity this + // room does not have, and so does the paste. + onNodeWithText("Relays").assertDoesNotExist() + onNodeWithText("Posts").assertDoesNotExist() + onNodeWithText("Curated schemas").assertDoesNotExist() + onNodeWithText("Curated entries").assertDoesNotExist() + onNodeWithText("Propose event").assertDoesNotExist() + + // The rest of the screen is untouched, so the rows' absence is an + // absence rather than a screen that failed to draw. + onNodeWithText("Subgroups").assertIsDisplayed() + } + + private fun state( + groupNostrProfile: GroupNostrProfile?, + relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), + posts: List = emptyList(), + schemas: List = emptyList(), + entries: List = emptyList(), + canEditNostrProfile: Boolean = true, + mlsGroupState: String? = "state" + ) = ChatRoomDetailUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom(mlsGroupState), + groupKeyState = GroupSignedWorkFixtures.groupKeyState, + groupNostrProfile = groupNostrProfile, + groupRelayLists = relayLists, + groupPosts = posts, + groupCuratedSchemas = schemas, + groupCuratedEntries = entries, + canEditNostrProfile = canEditNostrProfile + ) + + /** + * The screen at a phone's width and a window tall enough to compose the whole + * of it, since a `LazyColumn` does not lay out what it would not show. + * + * The state is passed in rather than loaded, so the NO_OP repositories are + * never asked for anything: `initiateChatRoomDetail` only runs for a screen + * that arrives in [ChatRoomDetailUIState.Loading]. + */ + private fun render( + uiState: ChatRoomDetailUIState, + onNavigateToRoute: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 4000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + ChatRoomDetailScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialChatRoomDetailUIState = uiState, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onPopBackToRoute = {}, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} From 69050bb743e364688831b1654a3893558738d176 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 15:21:13 +0200 Subject: [PATCH 27/71] docs: plan npub sign-in, starting from what a read-only identity is for The nsec plan's out-of-scope note said a read-only mode is a product, not a branch. This plan takes that at its word: what a public key can see here is thin -- a profile card, its follows as search results, no feed, no rooms, since every room is MLS or a gift wrap to the key that was not pasted -- so the first section decides what such an identity is for before anything is designed. It is a preview: the app as your own profile, before you paste a secret into it. That one word settles the Messages tab (an empty state that offers the upgrade), pasting the nsec of a read-only key (an upgrade in place under the same id, not "already on this device"), sign out (real for this kind only), and not-found (try again or a different key, never set one up). Eight phases: a nullable key on Identity, with the note that the compiler will be silent about it; a plaintext list beside the two key files, app-side through the public getDatadir and AtomicFileWrite so nothing needs a JitPack tag; the third startup branch, a read-only KeyPair built in one place, and only the two pumps that read; the sign-in screen, where hex stays a secret because an x coordinate is almost always also a valid scalar; a LocalCanSign capability and an inventory of every write entrance one tap from the three tabs; two exits; two round trips; rollout. Two traps found on the way are recorded where they bite: quartz's KeyPair(privKey = null) generates a fresh key rather than meaning "no key", and decryptGiftWrapSeal forwards exactly that; and signInToProfile plants a second kind 0 on a second call, which nothing reached until the upgrade path. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@78807fe956a212885ffffd0d340647b94c1fc23b --- docs/README.md | 5 + docs/npub-sign-in.md | 870 +++++++++++++++++++++++++++++++++++++++++++ docs/nsec-sign-in.md | 3 +- 3 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 docs/npub-sign-in.md diff --git a/docs/README.md b/docs/README.md index feb75ecb..c757cb9c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ silent, or a decision that looked arbitrary and was not. | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | | [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable | +| [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 | | [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 | | [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 | @@ -44,3 +45,7 @@ The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the code open, and read its table of where the build chose differently first. +The npub sign-in note is a phased plan that has not been built, and reads as that +plan's out-of-scope note answered: it takes the `Identity` type and the sign-in +machine as given and asks what an identity with no secret is for, before it asks +how to build one. diff --git a/docs/npub-sign-in.md b/docs/npub-sign-in.md new file mode 100644 index 00000000..b4835858 --- /dev/null +++ b/docs/npub-sign-in.md @@ -0,0 +1,870 @@ +# Signing in with an npub + +How a user looks at this app as a profile they hold no secret for, what such an +identity can and cannot do here, and why the answer to "what is it for" decides +every screen it touches. + +Read this after [nsec-sign-in.md](./nsec-sign-in.md). It inherits the `Identity` +type, the two key stores, the sign-in screen and the not-found exit from there, and +most of what follows is that plan's out-of-scope note taken at its word — "a +read-only mode is a product, not a branch" — and asked what the product would be. + +**Not built.** Phases 1–8 below, one commit each, in the order given. Phase 1 is a +type change and ships alone. Phases 2 through 5 ship together, for the reason in +[Phase 8](#phase-8--rollout): a build that can make a read-only identity but still +offers it "New chat" is the failure this plan exists to avoid. Nothing here touches +the library. + +## The constraint + +A nostr public key can be addressed and can verify. It cannot do anything else, +and in this app "anything else" is most of the app: + +| what needs the secret | where | +|---|---| +| signing every queued event — the kind 0, the follow list, key packages, group posts, proposals | `NotaryViewModel.observeUnsignedNostrEvents` | +| opening a NIP-17 gift wrap addressed to the user | [GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt) — NIP-44 with the recipient's key | +| joining a Marmot group — a key package has to be signed and published before any welcome can be addressed to it, and the welcome itself arrives as a gift wrap | `MarmotRepository.publishMarmotKeyPackageBundle`, `MarmotInboundManager` | +| the ChillDKG host key, `sha256(tag ‖ nostrPrivateKey)` | [ChillDkgRitualManager.kt:117](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt) | + +What a public key *gets* is the events it has published — the kind 0, the kind 3, +the relay lists — and the profiles of the people it follows, because indexing a +kind 3 plants a placeholder for each and queues its sync +([NostrDao.kt:1128](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +In a client with a public feed, that is the client: read-only in Damus or Amethyst +means "browse as this person". + +**This app has no public feed.** The home tab is a list of chat rooms, and every +room is MLS or a gift wrap, both encrypted to the key the user did not paste. The +curated lists, proposals, ceremonies and translations are reached only through a +room (`ChatRoomDetailScreen`). Search is over profiles, and trending notes is a +string that says "coming soon". So a read-only identity here sees its own profile +card, the people it follows — as search results, by name, once the sync has +fetched them; nothing renders the list itself — any profile's detail, and share +profile. That is the honest inventory, and it is thin — thin +enough that the first thing to decide is not how to build it but what it is +[for](#the-one-decision-to-make-first). + +## What is already built + +More than the nsec plan's out-of-scope note suggests, because that plan built most +of it on the way. + +| piece | where | state | +|---|---|---| +| the parser recognises `npub1…` and `nostr:npub1…` — and refuses it, as `PublicKeyOnly` | [CredentialParser.kt:94](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt) | one branch from accepting it | +| `signInToProfile(pubkey)` plants the `GENESIS_AT` placeholder from a pubkey alone | [DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | built; needs one property, see [Phase 4](#a-sign-in-that-can-be-repeated) | +| the state machine from there — `UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded` — reads `identity.nostrPublicKey` and never a key | [NavigationViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built | +| the sign-in sync, the indexer relays, the one hop, and the not-found exit | `SignInSync`, `UnsyncedProfileViewModel` | built; the exit needs a third action, see [Phase 6](#phase-6--leaving) | +| `RelaysSocketManager` follows the identity's relay list by pubkey | [RelaysSocketManager.kt:70](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) | works unchanged | +| the notary returns before doing anything when there is no key | [NotaryViewModel.kt:72](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt) | works unchanged — and so the key package bundle it would publish is never attempted | +| quartz's `KeyPair(pubKey = …)` with no private key, commented in its own source as "this is a read-only account" | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | usable; it is why the DAO carries `privKey!!` at all | +| `Identity.business` is nullable; a read-only identity is `NostrSecret` minus the secret | [Identity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt) | one field from it | +| the forget flow — key out, prefs deleted, metadata hidden, account rows gone, re-list, selector | `NostrSecretViewModel.forgetKey`, `IdentityWriter.forgetNostrKey` | built for an nsec; the sequence transfers whole | +| `WalletsSelector` shows the npub for every kind | [WalletsSelector.kt:161](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | needs a "read only" label | +| `SeedManager.getDatadir` and `AtomicFileWrite.writeVerified` are public library API | `fr.acinq.phoenix` | the app can keep a third file beside the two without a library change | +| a string, `this_will_give_you_read_only_access_to_the` | `strings.xml` | present, unused, and says too little | + +## What actually blocks it + +Five things, in dependency order. + +**The identity's key is not optional.** `Identity.nostrPrivateKey` is a +`PrivateKey` and `nostrPublicKey` is computed from it +([Identity.kt:50](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt), +62). Every reader of the identity compiles against that. + +**There is nowhere to keep a public key.** Both stores hold secrets. `nostr-keys.dat` +is a map from public key to private key, and `EncryptedNostrKeys` refuses a file +whose entry does not derive the key it is filed under — which is the right rule for +that file and rules out a sentinel (see the +[appendix](#a-sentinel-entry-in-nostr-keysdat)). + +**The pumps are gated on the private key.** `SynchronizationViewModel` runs its +four collectors inside `identity?.nostrPrivateKey?.let { … }` +([SynchronizationViewModel.kt:194](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt)). +A read-only identity's sign-in sync would be queued and never dispatched, and the +machine would park on `UnsyncedProfile` for good — the nsec plan's Phase 5 failure +again, from a different cause. + +**The DAO opens what is addressed to it, or rolls back.** `storeNostrEvent` is +`@Transaction` and indexes inside it; a gift wrap addressed to the active key that +cannot be unsealed throws +([NostrDao.kt:448–459](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)), +and the throw takes the event with it. Worse, and not obvious: `decryptGiftWrapSeal` +builds `KeyPair(privKey = keyPair.privKey)` +([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)), +and in quartz a `KeyPair` given neither key *generates a fresh random one*. A +read-only pair forwarded there would try to open the wrap with a key nobody has, +fail, and roll back — silently correct-looking in the logs. + +**Every write entrance is unconditional.** Some twenty-five view models write +something the notary has to sign or a key has to seal. Most are behind a chat +room and unreachable without one; but the "New chat" button, +follow, send message, key package management, key recovery and the not-found +screen's "set one up" are each one tap from the three tabs. A pasted npub that +lands on a screen offering "New chat" is not a preview, it is a broken app. + +## The one decision to make first + +What is a read-only identity *for?* Three answers were considered. + +**Browsing.** Needs a public feed. This app does not have one, and a mode whose +whole content is "coming soon" should not ship ahead of it. + +**Auditing a group from outside.** The curated lists a group publishes are public +events (kinds 31889, 31888, 31890), and a member's device is not the only place they +could be read. But nothing today reaches them except through a room, and building +a public list browser is a feature with its own screens — the feed problem in a +smaller shape. + +**A preview.** Look at the app as your own profile — the name it has for you, the +people it knows you follow, what it found on the relays — before you paste a secret +into it. And, having looked, paste it. + +**This plan takes the third.** It is what the thin inventory is actually good for, +and it settles four questions that would otherwise each be an argument of their own: + +- The Messages tab shows an empty state that says why it is empty and offers the + one thing that fills it: signing in with the nsec. +- Pasting the nsec of a key already here read-only is an **upgrade in place**, not + `AlreadyOnThisDevice`. The device does not have that key; refusing it would be + false. +- Sign out is real for this kind, and only this kind: there is nothing on the + device to lose. It is the first sign out in the app, and the general case stays + pending for the reason the nsec plan gave. +- Not-found offers *try again* or *a different key*, never *set one up* — a profile + cannot be set up for a key that cannot sign its kind 0. + +Everything below is downstream of that one word, preview, and if it is ever +revisited — a public feed lands, say — the places to change are the four above. + +--- + +## Phase 1 — a key the identity may not have + +**App only. No behaviour change.** A type change that ships alone, so its diff is +boring. + +### The type + +```kotlin +enum class IdentityKind { + Mnemonic, + NostrSecret, + /** A bare nostr public key. Nothing can be signed, opened or derived from it. */ + NostrPublic, +} + +data class Identity( + val id: WalletId, + val kind: IdentityKind, + val nostrPublicKey: HexKey, + /** Null only for [IdentityKind.NostrPublic]. */ + val nostrPrivateKey: PrivateKey?, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + val business: PhoenixBusiness?, +) { + /** What the notary, the pumps and every write entrance ask. */ + val canSign: Boolean get() = nostrPrivateKey != null + + init { + require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) { "kind and key disagree" } + require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) { "key does not derive its public key" } + } + + override fun toString(): String = "Identity(id=$id, kind=$kind, key=)" + + companion object { + fun signing(id: WalletId, kind: IdentityKind, privateKey: PrivateKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs, business: PhoenixBusiness?): Identity + fun readOnly(id: WalletId, nostrPublicKey: HexKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs): Identity + } +} +``` + +`nostrPublicKey` moves from a derived property to a constructor field, because for +one kind there is nothing to derive it from; the two `require`s in `init` keep the +field and the key from disagreeing, which a data class would otherwise happily +allow. The factories exist so that the two places that build a signing identity +today — `SovereignWalletViewModel.setActiveWallet` and the nsec branch of +`SovereignWalletStartupScreen` — keep deriving the pubkey in one place, and so that +nobody constructs a read-only identity with a key by accident. + +`id` for a read-only identity is `toWalletId()` of the x-only key — the **same id** +its nsec would have. That is deliberate and it is what makes the upgrade in +[Phase 2](#the-upgrade-rule) free: preferences and metadata are keyed by id, and a +read-only identity that becomes a signing one keeps both. + +### The readers + +Every reader of `nostrPrivateKey` already reaches it through `?.` on a nullable +identity — `activeIdentityStateFlow.value?.nostrPrivateKey` in the DKG, chat-room +and subgroup view models, `.map { it?.nostrPrivateKey }` in the notary, +`identity?.nostrPrivateKey?.let` in the pumps. Making the field nullable changes +the type of none of those expressions, and **the compiler will be silent**. That +is worth saying plainly, because it is the opposite of Phase 1 of the nsec plan, +where twenty-five parameter types forced every site to be looked at: here nothing +forces anything, and the sites that need a decision are found by reading, in +[Phase 5](#phase-5--what-a-read-only-screen-shows), not by compiling. + +One site does change. `KeyRecoveryScreen` branches on `activeIdentity?.kind` with +`NostrSecret` in one arm and `else` in the other +([KeyRecoveryScreen.kt:177](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt)), +and the `else` meant *mnemonic*. A read-only identity would fall into it and be +offered a recovery phrase it does not have. Make both `when`s in that file +exhaustive; the `NostrPublic` arm is unreachable after Phase 5 hides the row, and +an unreachable arm that says so is better than an `else` that will mean something +different next time a kind is added. + +### Tests + +`NavigationIdentityRoutingJvmTest` has a `nostrSecretIdentity()` fixture and the +case that a nodeless identity is routed by its account rather than to startup. It +gains a `readOnlyIdentity()` fixture and the same case: with the placeholder +account, `UnqueuedProfileSynchronization`; with a kind 0 indexed, `ProfileLoaded`. +And the two `require`s each get a one-line negative test, because they are the +whole reason the field is a constructor parameter. + +--- + +## Phase 2 — the list of public keys + +**App. Needs Phase 1.** A third file beside the two, and the writer that keeps it. + +### A plain file, on purpose + +`nostr-public-keys.json`, in the directory `SeedManager.getDatadir(phoenixGlobal.ctx)` +returns — the same durable, app-private location as `seed.dat` and +`nostr-keys.dat`, for the same reason: a cache directory is purged under storage +pressure, and an identity that vanishes on a low-storage day is a support ticket. + +```json +{ "version": 1, "publicKeys": ["", "…"] } +``` + +Not encrypted, because there is nothing to protect: a public key is public, and +the nsec plan's [rejection of plaintext](./nsec-sign-in.md#plaintext-in-datastore) +was about a key that signs as the user. Written through `AtomicFileWrite.writeVerified` +all the same — not because a truncated list is a catastrophe, but because the +discipline costs one call and the alternative is a second way of writing a file in +a directory that has one. + +**In the app, not the library.** `nostr-keys.dat` went into the library because it +needed the keystore's `expect`/`actual`s. This file needs a directory and an atomic +write, and both are public API. Keeping it out of the library is not a small +thing: the nsec plan's Phase 8 begins with "library first — tag it, since JitPack +consumers resolve by tag", and this plan's does not have to. + +**Not in `GlobalPrefs`** — for the same reason (`GlobalPrefs` is a library class, +and its preference keys are `private`) and one more: `DataStoreManager` caches preferences per id for +the life of the process, which `IdentityWriterJvmTest` already has to work +around. A file is read when it is read. + +```kotlin +package press.mantra.compose.identity + +object NostrPublicKeyStore { + sealed interface ReadResult { + data class Success(val publicKeys: Set) : ReadResult + data object FileNotFound : ReadResult + data object FileUnreadable : ReadResult + data object SerializationError : ReadResult + } + fun read(phoenixGlobal: PhoenixGlobal): ReadResult + fun readOrNull(phoenixGlobal: PhoenixGlobal): Set? // empty set when absent, null on failure + fun write(phoenixGlobal: PhoenixGlobal, publicKeys: Set) +} +``` + +Three failures rather than `NostrKeyManager`'s five, because there is no key store +in the path. `read` validates each entry as sixty-four hex characters and drops — +with a log line — any that is not, rather than refusing the file: a corrupt entry +in a list of public keys costs one identity, and a refused file costs all of them. + +### Listing + +```kotlin +sealed interface StoredIdentity { + … + data class NostrPublic( + override val id: WalletId, + override val nostrPublicKey: HexKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrPublic + } +} +``` + +`StoredIdentity.merge` takes a third argument and applies one rule: **a secret wins +over a public key**. A pubkey present in `nostr-keys.dat` or derived from a seed +and also in the list is listed once, as the signing kind, and the list entry is +logged as stale. It cannot happen after the writers below have run to completion, +and it can happen if the process dies between their two writes; the merge is where +the two files are reconciled, and the next write repairs the list. + +### The writers + +```kotlin +object IdentityWriter { + … + suspend fun writeNostrPublicKey(log, phoenixGlobal, globalPrefs, publicKey: HexKey, isTorEnabled, customElectrumServer): WriteNostrPublicKeyResult + suspend fun forgetNostrPublicKey(log, phoenixGlobal, id: WalletId, publicKey: HexKey): ForgetNostrPublicKeyResult +} +``` + +Same shape as `writeNostrKey` and `forgetNostrKey`: load, refuse a duplicate **by +public key across all three stores**, write, `prepareIdentity`. The Tor and +Electrum preferences mean even less to a read-only identity than to an nsec one; +they are saved for the reason the nsec plan gave — `loadUserPrefsForWallet` is what +creates the file. + +### The upgrade rule + +`writeNostrKey` refuses a key whose public key is already known. That check learns +one distinction: + +| the pubkey is already here as | pasting its nsec | pasting its npub | +|---|---|---| +| a wallet (seed) | `AlreadyExists` | `AlreadyExists` | +| an nsec | `AlreadyExists` | `AlreadyExists` | +| a public key | **upgrade**: write the key, then remove the list entry, `Written(id)` with the id unchanged | `AlreadyExists` | + +`writeMnemonic` gets the same upgrade for a seed whose nostr key is in the list — +remove the entry; the wallet's id is `hash160(nodeId)` and its preferences are +fresh, which is what a new wallet gets today. + +**The order of the two writes is not a style choice.** Key first, then the list. A +crash between them leaves the pubkey in both files, which `merge` resolves in +favour of the secret; the reverse order would leave it in neither, which is an +identity gone. + +### Tests + +`StoredIdentityJvmTest` gains: three stores land in one map, each under its own +id; a pubkey in the list and in `nostr-keys.dat` is listed once as `NostrSecret`. +`IdentityWriterJvmTest` gains: a public key is written once and refused the second +time; its nsec is then accepted, under the same id, and the list no longer holds +it; the npub of an nsec already here is refused; forgetting a public key leaves the +others and takes its preferences. A layout test for the JSON — the version field +and one entry, as literal bytes — sits beside them, because the file is a +compatibility contract from the moment it exists. + +--- + +## Phase 3 — listing, starting, and reading without a key + +**App. Needs Phase 2.** + +### Listing and starting + +`SovereignWalletViewModel.listIdentities` reads the third store after the second, +with `ReadResult.FileNotFound` as the empty set and the other two failures surfaced +as `ListWalletState.Error`, the way the other two files are handled +([SovereignWalletViewModel.kt:242](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)). + +`SovereignWalletStartupScreen` gains its third branch, beside the nsec one +([SovereignWalletStartupScreen.kt:185](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)): + +```kotlin +is StoredIdentity.NostrPublic -> sovereignWalletViewModel.setActiveIdentity( + Identity.readOnly( + id = identity.id, + nostrPublicKey = identity.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + ) +) +``` + +No node, no `platformStartupLogic`, active the moment it is read — exactly the nsec +branch. The screen-lock gate wraps all three, as it was built to. + +`WalletsSelector` shows the npub for every kind already; a read-only row adds the +words *read only* under it, in `labelSmall`, so the user can tell which of two +identities with the same avatar will let them send a message before they tap. + +### The pumps + +`SynchronizationViewModel`'s collector becomes + +```kotlin +activeIdentityStateFlow.collectLatest { identity -> + identity?.let { + val keyPair = identity.toKeyPair() + … + supervisorScope { + launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) } + launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) } + if (identity.canSign) { + launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } + launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + } + } + } +} +``` + +**A read-only identity runs the two pumps that read and neither of the two that +write.** Stated as a rule, because Phase 5 leans on it: *a read-only identity never +sends anything to a relay*, not a signature and not a copy. The broadcast pump +would find nothing — nothing is ever signed, and the one control that queues an +already-signed event is hidden — and the +live subscriptions are the gift-wrap inbox and the group-membership follow, both of +which fetch things this identity cannot open, and the catch-up negentropy for the +same. Not running them is not an optimisation; it is the identity not asking for +what it cannot use. The sync pump and the negentropy pump are what the sign-in +sync, the one hop and *try again* are queued on, and they are all a preview needs. + +`Identity.toKeyPair()` is the one place a quartz `KeyPair` is built from an +identity: + +```kotlin +fun Identity.toKeyPair(): KeyPair = KeyPair( + privKey = nostrPrivateKey?.value?.toByteArray(), + pubKey = nostrPublicKey.hexToByteArray(), +) +``` + +With a private key, quartz recomputes the public key from it and the second +argument is a cross-check. Without one, it is the read-only constructor. What it +must never be is `KeyPair(privKey = null)` with nothing else — that is quartz's +"make me a new key", and it is what `decryptGiftWrapSeal` does today +([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)). +The helper exists so that the trap has one place to be avoided; leave a comment on +it that says what the trap is. + +### The one DAO guard + +After the `isAddressedTo` check in `indexNostrEvent` +([NostrDao.kt:439](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)): + +```kotlin +if (activeKeyPair.privKey == null) { + // Addressed to us, and we cannot open it. Keep the event and the wrap we just + // stored, as the branch above does for wraps addressed to someone else; the + // key that opens this one may be signed in later. + logger.d("GiftWrap ${nostrEvent.id} is addressed to us, but this identity holds no key") + return@let +} +``` + +The two `privKey!!` sites further down (lines 898 and 1376) are downstream of a +successful unseal and a Marmot room this device is in; a read-only identity reaches +neither, and they stay as they are. With the live subscriptions off, and once +Phase 5 leaves the room list uncomposed, a gift wrap addressed to a read-only +identity arrives only if something else asks for kind 1059 — nothing else does — +so this guard is belt and braces. It stays because the alternative, when something +does ask, is a rolled-back transaction that logs as a decryption failure and is +not one; and between this phase and Phase 5 it is not belt and braces at all, it +is what keeps the room list's inbox sync from rolling back every wrap it fetches. + +### Tests + +`NostrDaoJvmTest` gains one case: a gift wrap addressed to a read-only `KeyPair` +is stored, its `GiftWrapMessage` row is stored, no seal is stored, and nothing +throws — `NostrNip17DaoJvmTest` has the fixtures for building the wrap. The +listing itself is covered where the nsec plan ended up covering it: `merge`, in +`StoredIdentityJvmTest` (Phase 2), and the round trip in Phase 7, which lists +through the view model against a real directory. The nsec plan asked for a +`SovereignWalletViewModel` listing test of its own and the build did not write +one; this plan does not pretend to. + +--- + +## Phase 4 — the sign-in screen + +**App. Needs Phase 3.** The field accepts a third thing. + +### Shape + +| pasted | recognised as | validation | +|---|---|---| +| words | recovery phrase | unchanged | +| `nsec1…`, `nostr:nsec1…` | nostr secret | unchanged | +| 64 hex characters | **nostr secret, still** | see below | +| `npub1…`, `nostr:npub1…` | nostr public key, read only | bech32 decodes with hrp `npub` to 32 bytes that are an x coordinate on the curve — `XonlyPublicKey(bytes).publicKey.isValid()`, the counterpart of the `isValid()` a secret is checked with | +| `ncryptsec1…` | rejected, with the reason | unchanged | + +**Hex stays a secret.** A private key and an x-only public key are both thirty-two +bytes, and sixty-four hex characters cannot say which it is. The parser has always +read hex as a secret and shown the derived npub on the confirm step so a wrong +paste is visible; a user who pastes a public key as hex will see an npub they do +not recognise and go back. Guessing — "it is not a valid secret, so try it as a +public key" — would never fire when it mattered: a public key's x coordinate is, +with overwhelming probability, also a valid scalar, so the parser would accept it +as a secret, derive an unrelated npub, and never reach the guess. An npub has to +arrive as an npub. + +`CredentialProblem.PublicKeyOnly` goes, and with it the string +`an_npub_is_a_public_key_mantra_needs_the`. An npub that does not decode, or +decodes to a point that is not on the curve, is `InvalidKey`, which already says +"that nostr key is not valid". + +### The confirm step + +`SignInCredential` gains + +```kotlin +data class NostrPublicKey(override val nostrPublicKey: HexKey) : SignInCredential +``` + +and `Confirm` a third arm, `Icons.Default.Visibility` beside a sentence that says +what the user is about to get and not get. `this_will_give_you_read_only_access_to_the` +says "This will give you read only access to the profile", which is true and not +enough: it does not say that messages stay closed, and it does not say what to do +about it. Replace it: + +> Recognised as a public key. You will see this profile and the people it +> follows; messages stay closed and nothing can be sent. Paste the nsec later to +> open it. + +Sentence case, one string, read on the confirm step and nowhere else. The button +still says *Sign in*. + +### Committing + +```kotlin +is SignInCredential.NostrPublicKey -> when (val result = writeNostrPublicKey(credential.nostrPublicKey)) { + is Written -> Outcome.SignedIn(result.id) + is AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) +} +``` + +then `signInToProfile`, then the same tail as the other two: re-list, select, go to +startup. Startup finds a `StoredIdentity.NostrPublic`, activates it, and the machine +runs from the placeholder exactly as it does for an nsec — the sync pump is running +because Phase 3 made it run. + +### A sign-in that can be repeated + +`signInToProfile` inserts a new kind-0 `UnsignedNostrEvent` every time it is called +([DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)). +Until now nothing called it twice for one pubkey: the writers refused the second +sign-in before it got there. The upgrade is the first path that signs in a pubkey +whose account already exists, and a second placeholder would be "two kind-0 rows +for one pubkey, two accounts disagreeing about which is this one" — the failure +`setUpProfileForExistingKey`'s own comment describes. + +Make it idempotent by pubkey: if a kind-0 `UnsignedNostrEvent` exists for the key, +do nothing. That is the right property regardless of the upgrade, and the existing +test `both credentials of one key plant the same account` becomes `…plant one +account, however many times they sign in`. + +### Landing + +The caption under *Sign in* names what the screen accepts, and the label and +placeholder on the field do too. Three strings change to say "a recovery phrase, +an nsec, or an npub to look around" — the last clause because a user who does not +know the word *read-only* should still be told the difference before they paste. + +### Tests + +`CredentialParserJvmTest`: an npub, a `nostr:`-prefixed npub and the upper-cased +form all name the same public key; an npub that does not decode is an invalid key; +sixty-four hex characters are still a secret. `SignInToProfileViewModelJvmTest`: a +public key is written, then its account is planted, then the id comes back; the +npub of a key already here is refused and plants nothing; the nsec of a key here +read-only is written under the same id and plants nothing new. + +--- + +## Phase 5 — what a read-only screen shows + +**App. Compiles on Phase 1; visible after Phase 3.** The half the nsec plan called +a product. + +### One question, asked in one way + +Screens do not receive the identity. `HomeScreen`, `ActiveProfileScreen` and the +detail widgets take an `activeUserPublicKey`, and `MetadataEventDetail` — where +*follow* and *send message* live — is several composables below anything that +could be handed more. Threading `canSign` down would be the snackbar host's +problem again: the same parameter in twenty-six lists and forgotten in the +twenty-seventh. + +```kotlin +// ui/composable/widgets/SigningCapability.kt +val LocalCanSign: ProvidableCompositionLocal = staticCompositionLocalOf { true } + +@Composable +fun ProvideSigningCapability(activeIdentity: StateFlow, content: @Composable () -> Unit) +``` + +Provided once, in `MantraNavHost` around the `NavHost`, from +`sovereignWalletViewModel.activeIdentity`. The default is `true` rather than an +error, and that is the one way this differs from `LocalSnackbarHostState`: the +provider sits above every screen and cannot be forgotten per screen, so the only +things composed outside it are previews and the existing compose tests, and those +should render as they always have. + +It is a capability, not a kind. A screen asks "can this identity sign?", not "is +this an npub?", because the answer is what it needs and because a remote signer — +which can sign and holds no local key — would otherwise be a fourth value in every +`when` (see [Out of scope](#out-of-scope)). + +### The inventory + +Every write entrance reachable from the three tabs without a chat room, and what +a read-only identity gets instead. A row that says *hidden* is hidden, not +disabled: a disabled "New chat" invites the question "why", and the answer is on +the empty state beside it. + +| screen | control | it writes | read only | +|---|---|---|---| +| `HomeScreen` | *New chat* (both layouts), the sheet, the npub dialog | key packages, welcomes, gift wraps | hidden; the tab shows the [empty state below](#the-messages-tab) | +| `HomeScreen` | `ChatRoomListViewModel.initiate` | queues the inbox sync and the MLS negentropy | not composed, so not queued | +| `MetadataEventDetail` | *follow*, *unfollow*, *follow back* ([:215](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a kind 3 | hidden; the "follows you" state still shows | +| `MetadataEventDetail` | *send message* ([:303](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a gift wrap | hidden | +| `MetadataEventDetail` | *edit profile* | a kind 0 | hidden (it is `ImplementationPendingRoute` today, and stays so for the other kinds) | +| `ActiveProfileScreen` | *key package management* ([:233](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | key packages | hidden | +| `ActiveProfileScreen` | *key recovery* ([:286](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | nothing, but there is nothing to recover | hidden | +| `ActiveProfileScreen` | *sign out* ([:341](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | — | **real**, see [Phase 6](#phase-6--leaving) | +| `ShareProfileScreen` | *re-broadcast* ([:191](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt)) | nothing is signed, but a copy of the kind 0 is queued for the finder relays | hidden; a preview puts nothing on a relay, not even a copy, and the broadcast pump is not running to carry it | +| `UnsyncedProfileScreen` | *set one up* ([:217](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt)) | the six-event bootstrap | hidden; replaced, see Phase 6 | +| `SocialPreconditionScreen` | *invite a friend*, *view invites* | pending, and writes when they exist | hidden; only *skip for now* | +| `WriteNewNoteScreen` and the rest | — | — | unreachable: every route to them starts in a room or behind a control above | + +Two things are *not* on the list because they are already handled. The notary's +key package bundle is behind its key check, so a read-only identity never publishes +one. And the DKG, chat-room and subgroup view models each answer a missing key with +an error state — they are unreachable, and if they were reached they would say so +rather than crash. + +### The Messages tab + +Where the room list would be, for a read-only identity: + +```kotlin +EmptyState( + message = stringResource(Res.string.messages_need_the_secret_key_this_profile), + icon = Icons.Default.Lock, + action = { + TextButton(onClick = { onNavigateToRoute(SignInRoute) }) { + Text(stringResource(Res.string.sign_in_with_the_nsec)) + } + }, +) +``` + +> Messages need the secret key. This profile is read only: you can see it and the +> people it follows, but nothing here can be opened or sent. + +`EmptyState`'s message is required for exactly this reason — an absence has to say +which absence it is — and its `action` slot is where the upgrade lives. The +`SignInRoute` it opens is the same screen as landing's; the nsec pasted there hits +the upgrade rule, and the tail takes the user through startup to a Messages tab +with rooms in it. + +One column at every width. The two-pane layout is a list beside a detail, and +there is no list; an empty state that is the only thing on the screen is the one +case `readableContent()`'s centring is for. + +The tab stays. Hiding it would leave a navigation bar with two items — the +conformance work promoted search and profile to peers precisely so the bar would +not be "strictly worse than the app bar it replaced", and two items is halfway +back to that — and the bar is where the upgrade is found. + +### Tests + +`ReadOnlyEntrancesJvmTest`, with `runDesktopComposeUiTest` as `ChatPaneLayoutJvmTest` +does: `HomeScreen` in its loaded state under `LocalCanSign provides false` has no +node with the text *New chat* and one with *Sign in with the nsec*; under `true`, +the reverse. `ActiveProfileScreen` under `false` has no *Key recovery* and has +*Sign out*. The inventory above is the list of assertions; a row without one is a +row that will regress. + +And `./gradlew :composeApp:m3Audit` — an empty state with an action and a new +`when` arm is where a `16.dp` and a "Read Only" arrive. + +--- + +## Phase 6 — leaving + +**App. Needs Phases 2 and 5.** Two exits, one sequence. + +### Sign out, for the kind that can + +`NostrSecretViewModel.forgetKey` does five things in an order that matters — key +out of the file, preferences deleted, metadata hidden, account rows gone, then the +caller re-lists and clears the active identity — and the order is documented on the +function: a failure partway leaves the key on disk rather than an identity the +selector lists but nothing can open. The sequence is the same for a read-only +identity with the first step swapped. Lift it into a `ForgetIdentity` helper that +takes the identity and dispatches the first step on its kind, returning +`NotABareKey` for a mnemonic one, and have `NostrSecretViewModel` call it. The diff +will show whether that is one function or two. + +`ActiveProfileScreen`'s *sign out* then does, for a read-only identity only, what +its colour has been promising: a confirmation dialog naming the npub — + +> Sign out of this profile? This device holds no key for it, so there is nothing +> to lose. The profile stays on the relays, and you can sign in again any time. + +— and `ForgetIdentity`, then the nav host's existing tail +(`listIdentities { resetToSelector() }`, which shows the selector or, if this was +the last identity, Landing). For the other two kinds the button keeps routing to +`ImplementationPendingRoute("Sign out")`, and the nsec plan's reason stands: +removing a seed is a wallet question. + +### The not-found exit + +`UnsyncedProfileScreen`'s not-found state offers *try again* and *set one up*. For +a read-only identity the second is hidden by Phase 5 — and then the state has +**no exit**: the profile tab is not reachable before `ProfileLoaded`, so a user +whose npub was not found on any relay could try again for ever. That is the nsec +plan's "parks on a spinner" with the spinner replaced by a button. + +A third action, shown only where the second is not: *use a different key*, which +is `ForgetIdentity` behind the same confirmation and the same tail, landing on the +selector or Landing. The screen learns the two callbacks the way `NostrSecretScreen` +did — passed from the nav host — and reads `LocalCanSign` to decide which of the +two it shows. + +### Tests + +`ForgetIdentity` gets the `IdentityWriterJvmTest` treatment: a read-only identity +is forgotten and its list entry, preferences and account rows are gone; the other +entries remain. `UnsyncedProfileViewModel`'s existing decision test is unchanged — +not-found is still not-found — and the screen test asserts which action each kind +gets. + +--- + +## Phase 7 — the tests that actually prove it + +Two, beside `NsecRestoreRoundTripJvmTest`, whose fixture they reuse. + +**The preview, end to end.** Write an npub through Phase 2's writer, list +identities, activate the `NostrPublic` one, hand `NavigationViewModel` a repository +holding the placeholder: `UnqueuedProfileSynchronization`. The same account with a +kind 0 indexed: `ProfileLoaded`. Then the assertion the nsec round trip makes about +the node, made about the key: **nothing was signed**. The only `UnsignedNostrEvent` +for the pubkey is the placeholder, still at `GENESIS_AT`; no key package bundle +row; no broadcast request; `identity.business == null`; `identity.canSign` false. + +**The upgrade.** From the state above, write the nsec of the same pubkey through +`writeNostrKey`: `Written` with the same id; `nostr-keys.dat` holds the key and the +list does not; `signInToProfile` planted nothing new, and the account routes to +`ProfileLoaded` as before. Re-list: one identity, `NostrSecret`, same id, same +metadata. + +--- + +## Phase 8 — rollout + +**No library change.** Every file this plan touches is in `composeApp`, so there is +no submodule pointer to bump and no tag to cut. That is the one place this rollout +is simpler than the nsec plan's, and it is because Phase 2 chose the app. + +**Phase 1 ships alone.** A type change with two `require`s and one exhaustive +`when`; if the release after it behaves differently, the cause is in one diff. + +**Phases 2 through 5 ship together.** A build with the store but not the screen is +harmless; a build with the screen but not the store strands the user at +"Initializing…"; and a build with both but not Phase 5 is the thing this plan is +for — an identity that looks signed in and offers "New chat". Phase 6 can follow +by a release: without it a read-only identity has no sign out and no not-found +exit, which is a dead end but not a lie. + +**Old builds and the new file.** A build without Phase 3 does not read +`nostr-public-keys.json` and does not know it exists. A downgrade loses sight of a +read-only identity and nothing else; `seed.dat` and `nostr-keys.dat` are untouched +throughout. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | a key the identity may not have | 0.5–1 | — | +| 2 | the list, its writers, the upgrade rule | 1 | 1 | +| 3 | listing, starting, the pumps, the DAO guard | 1 | 2 | +| 4 | the sign-in screen; an idempotent sign-in | 0.5–1 | 3 | +| 5 | the capability, the inventory, the Messages tab | 1 | 1 to compile, 3 to see | +| 6 | sign out, and the not-found exit | 0.5 | 2, 5 | +| 7 | the two round trips | 0.5 | 4, 5, 6 | +| 8 | rollout | — | all | + +**Roughly one focused week.** Phase 5 is where the estimate is least reliable: +the inventory was made by reading, and the compiler will not say whether it is +complete. The compose tests are how it is checked, and a row found late is a row +added to both. + +## Out of scope + +- **A public feed.** The thing that would make read-only *browsing* rather than + *previewing*. Its own plan, and when it lands the four decisions under + [the one decision](#the-one-decision-to-make-first) are the places to revisit. +- **NIP-46 remote signer, NIP-55 Android signer.** This plan builds half of what + they need — an identity whose secret is not on `Identity` — and none of the + other half, a signing interface the notary calls instead of a `NostrSignerSync`. + `LocalCanSign` is a Boolean on purpose: a remote signer answers it *true* — + which is why `canSign` is a property of the identity and not `nostrPrivateKey != null` + repeated at each site — and the question it adds is "can it derive?": the + ChillDKG host key needs the raw bytes, and no signer protocol gives them. A + second capability, not a third value of this one. +- **NIP-05 sign-in.** `alice@example.com` resolves to a pubkey with one HTTPS + request, and the app already accepts a NIP-05 for starting a message. It is a + natural fourth row in Phase 4's table, and it is a network round trip on a form + field, which is why `EditGroupCuratedSchemaViewModel.pubkeyOrNull` declined it. + A cheap follow-on once the npub path is proven. +- **NIP-49 `ncryptsec`.** Unchanged from the nsec plan. +- **Setting up a profile for a public key.** Impossible: the kind 0 has to be + signed. Said here so that nobody looks for the branch Phase 5 hid. +- **Reading a group's curated lists from outside it.** Public events, but every + path to them goes through a room today. The feed problem in a smaller shape. +- **Tightening secrets in memory.** Unchanged, and this plan adds no secret to + tighten. + +## Appendix — what was considered and rejected + +### A sentinel entry in `nostr-keys.dat` + +An empty-string value, or a zero key, under the pubkey. One file, one store, one +`merge`. Rejected because the file's contract is the reason it is trustworthy: +`EncryptedNostrKeys` refuses an entry whose key does not derive its pubkey, and a +sentinel is exactly such an entry. A second version byte for a file that exists to +hold secrets, so that it can hold a non-secret, is the wrong direction; and it +would be a library change, with the tag and the JitPack consumers that come with +one. + +### A fake private key + +Give a read-only identity a random `PrivateKey` so that nothing has to become +nullable. The notary would sign as a key nobody follows, the DAO would try to +unseal wraps with it, the key package bundle would be published under it, and +every one of those would look, in the logs, like success. The nsec plan rejected +the fake `LocalKeyManager` for the same reason: a real object in a state its +authors never intended. Nullable is honest. + +### Gating on the kind at each site + +`if (identity.kind == IdentityKind.NostrPublic) hide()`. Works today, and is the +wrong question: the site wants to know whether it can sign. When a remote signer +arrives — the nsec plan already names it — every such `if` is a bug, and the +compiler will point at none of them. + +### Hiding the Messages tab + +A read-only identity lands on its profile and the bar has two items. Rejected: the +bar is the navigation, M3 gives a navigation bar three to five destinations and the +conformance work made an IA decision to reach three, and the empty state is where +the upgrade is offered. An identity that cannot find the way to become a signing +one is a preview of nothing. + +### Running all four pumps regardless + +Let the broadcast pump idle and the live subscriptions fetch what they fetch; the +DAO guard keeps the wraps. Rejected for what it costs the user for nothing: +`GiftWrapMessage` rows that will never be opened, placeholder profiles for their +senders, profile syncs for each, and a socket held open for an inbox that cannot +be read. The pumps that run are the ones with something to do. + +### A Room table for the list + +`ReadOnlyIdentity(publicKey)`, listed by the repository. Rejected because the +startup listing is built from the key stores by a view model that has no +repository, and because the nsec plan's listing test exists to catch two stores +disagreeing about what an id is; a third store in a different layer, read at a +different time, is a third opinion. diff --git a/docs/nsec-sign-in.md b/docs/nsec-sign-in.md index 28e1a267..0b02b703 100644 --- a/docs/nsec-sign-in.md +++ b/docs/nsec-sign-in.md @@ -947,7 +947,8 @@ found. would accept it. But every write path in the app assumes a key: the notary would have nothing to sign with and the state machine would park on `UnsignedProfile` the first time anything was queued. A read-only mode is a - product, not a branch. + product, not a branch — and [npub-sign-in.md](./npub-sign-in.md) is the plan + for that product, starting from what it is for. - **NIP-49 `ncryptsec`.** A passphrase-encrypted nsec. quartz 1.14.0 ships `nip49PrivKeyEnc.Nip49.decrypt`, so it is one call and a passphrase field; it belongs in Phase 4's table once the plain nsec path is proven, and is a cheap From 10df01f4b775ce34f07017017c158f552ce789a3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 15:34:57 +0200 Subject: [PATCH 28/71] docs(npub): one credentials file, not two, and the write that makes the upgrade atomic The plan's first design kept public keys in a plaintext file beside nostr-keys.dat, app-side, so that nothing touched the library. Review asked why the key file was not simply made a credentials file with a read-only kind, and the answer is that it should be. The upgrade -- pasting the nsec of a key held read-only -- was the one operation spanning both files, so it was two writes with a crash window between them and a "secret wins" rule in merge to repair it; with one file keyed by pubkey it is a single write that replaces {type: public} with {type: secret}. Forget collapses to one path with it. And the advantage that paid for the two-file design was worth less than it looked: the library commit that introduced nostr-keys.dat is untagged, so a credentials format rides the tag that work already owes. Phase 2 is rewritten for nostr-credentials.dat: a typed entry per pubkey in the same envelope, renamed rather than versioned in place because an older build's listIdentities returns on SerializationError before it publishes anything and would list no wallets at all; a migration that reads the v1 file once and deletes it, since a frozen copy would resurrect a forgotten key on a downgrade; and the one upgrade that still spans two files -- a recovery phrase over a public credential -- with its order stated. The header, Phases 3, 6, 7 and 8, the estimate and the appendix follow, where the two-file design now sits as the rejected alternative with the reason. One overclaim of the revision's own is corrected in it: the library does not use a class discriminator anywhere -- its cloud payloads pick a variant by a version field -- so the plan says the discriminator is chosen here, not inherited. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@dc8a1091d35a3baa5f674c7a82a0a10c5580b910 --- docs/npub-sign-in.md | 308 +++++++++++++++++++++++++++---------------- 1 file changed, 193 insertions(+), 115 deletions(-) diff --git a/docs/npub-sign-in.md b/docs/npub-sign-in.md index b4835858..6e8bc042 100644 --- a/docs/npub-sign-in.md +++ b/docs/npub-sign-in.md @@ -10,10 +10,11 @@ most of what follows is that plan's out-of-scope note taken at its word — "a read-only mode is a product, not a branch" — and asked what the product would be. **Not built.** Phases 1–8 below, one commit each, in the order given. Phase 1 is a -type change and ships alone. Phases 2 through 5 ship together, for the reason in -[Phase 8](#phase-8--rollout): a build that can make a read-only identity but still -offers it "New chat" is the failure this plan exists to avoid. Nothing here touches -the library. +type change and ships alone. Phase 2 is the only phase that touches the library, +and it rides the push and tag the nsec plan's library commit still owes. Phases 3 through 5 ship +together, for the reason in [Phase 8](#phase-8--rollout): a build that can make a +read-only identity but still offers it "New chat" is the failure this plan exists +to avoid. ## The constraint @@ -62,7 +63,7 @@ of it on the way. | `Identity.business` is nullable; a read-only identity is `NostrSecret` minus the secret | [Identity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt) | one field from it | | the forget flow — key out, prefs deleted, metadata hidden, account rows gone, re-list, selector | `NostrSecretViewModel.forgetKey`, `IdentityWriter.forgetNostrKey` | built for an nsec; the sequence transfers whole | | `WalletsSelector` shows the npub for every kind | [WalletsSelector.kt:161](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | needs a "read only" label | -| `SeedManager.getDatadir` and `AtomicFileWrite.writeVerified` are public library API | `fr.acinq.phoenix` | the app can keep a third file beside the two without a library change | +| `nostr-keys.dat`, with a version byte and a doc that says "a new shape would be a new version" | `EncryptedNostrKeys`, `NostrKeyManager`, `AtomicFileWrite` | the envelope, the atomic write and the manager shape all carry over; only the entries change | | a string, `this_will_give_you_read_only_access_to_the` | `strings.xml` | present, unused, and says too little | ## What actually blocks it @@ -76,9 +77,9 @@ Five things, in dependency order. **There is nowhere to keep a public key.** Both stores hold secrets. `nostr-keys.dat` is a map from public key to private key, and `EncryptedNostrKeys` refuses a file -whose entry does not derive the key it is filed under — which is the right rule for -that file and rules out a sentinel (see the -[appendix](#a-sentinel-entry-in-nostr-keysdat)). +whose entry does not derive the key it is filed under — the right rule for that +shape, which is why the answer is a new shape rather than a value smuggled into +this one ([Phase 2](#phase-2--one-credentials-file)). **The pumps are gated on the private key.** `SynchronizationViewModel` runs its four collectors inside `identity?.nostrPrivateKey?.let { … }` @@ -228,59 +229,102 @@ whole reason the field is a constructor parameter. --- -## Phase 2 — the list of public keys +## Phase 2 — one credentials file -**App. Needs Phase 1.** A third file beside the two, and the writer that keeps it. +**Library, then app. The library half needs nothing above it; the app half needs +Phase 1.** The only phase that touches the library, and the one place this plan +changes one of the nsec plan's stores rather than adding to them. -### A plain file, on purpose +### The file that was written expecting this -`nostr-public-keys.json`, in the directory `SeedManager.getDatadir(phoenixGlobal.ctx)` -returns — the same durable, app-private location as `seed.dat` and -`nostr-keys.dat`, for the same reason: a cache directory is purged under storage -pressure, and an identity that vanishes on a low-storage day is a support ticket. +`EncryptedNostrKeys`'s own doc says it: "this file has one shape, and a new shape +would be a new version." A read-only credential is the new shape. Rather than a +second file beside `nostr-keys.dat` holding public keys in the clear — the design +this plan first had, and rejected for the reason in the +[appendix](#a-plaintext-sibling-file-for-public-keys) — the file becomes what its +name should have been: **`nostr-credentials.dat`**, one entry per nostr public key, +each entry saying what the device holds for it. ```json -{ "version": 1, "publicKeys": ["", "…"] } -``` - -Not encrypted, because there is nothing to protect: a public key is public, and -the nsec plan's [rejection of plaintext](./nsec-sign-in.md#plaintext-in-datastore) -was about a key that signs as the user. Written through `AtomicFileWrite.writeVerified` -all the same — not because a truncated list is a catastrophe, but because the -discipline costs one call and the alternative is a second way of writing a file in -a directory that has one. - -**In the app, not the library.** `nostr-keys.dat` went into the library because it -needed the keystore's `expect`/`actual`s. This file needs a directory and an atomic -write, and both are public API. Keeping it out of the library is not a small -thing: the nsec plan's Phase 8 begins with "library first — tag it, since JitPack -consumers resolve by tag", and this plan's does not have to. - -**Not in `GlobalPrefs`** — for the same reason (`GlobalPrefs` is a library class, -and its preference keys are `private`) and one more: `DataStoreManager` caches preferences per id for -the life of the process, which `IdentityWriterJvmTest` already has to work -around. A file is read when it is read. - -```kotlin -package press.mantra.compose.identity - -object NostrPublicKeyStore { - sealed interface ReadResult { - data class Success(val publicKeys: Set) : ReadResult - data object FileNotFound : ReadResult - data object FileUnreadable : ReadResult - data object SerializationError : ReadResult - } - fun read(phoenixGlobal: PhoenixGlobal): ReadResult - fun readOrNull(phoenixGlobal: PhoenixGlobal): Set? // empty set when absent, null on failure - fun write(phoenixGlobal: PhoenixGlobal, publicKeys: Set) +{ + "": { "type": "secret", "privateKey": "" }, + "": { "type": "public" } } ``` -Three failures rather than `NostrKeyManager`'s five, because there is no key store -in the path. `read` validates each entry as sixty-four hex characters and drops — -with a log line — any that is not, rather than refusing the file: a corrupt entry -in a list of public keys costs one identity, and a refused file costs all of them. +Same envelope — version byte, sixteen-byte iv, ciphertext of UTF-8 JSON under +`KeyStoreNames.KEY_NO_AUTH` — same atomic write through `AtomicFileWrite`, same +manager shape. The JSON is a `Map` with `NostrCredential` +a `@Serializable sealed class` and `type` its class discriminator — kotlinx's +standard sealed polymorphism, `Json { classDiscriminator = "type" }`, which the +library does not use elsewhere (its cloud payloads pick a variant by a version +field) and which is chosen here because the two variants have different fields and +the map then decodes in one call with no hand-written dispatch. + +```kotlin +package fr.acinq.phoenix.security + +@Serializable +sealed class NostrCredential { + @Serializable @SerialName("secret") data class Secret(val privateKey: PrivateKey) : NostrCredential() + @Serializable @SerialName("public") data object Public : NostrCredential() +} + +class EncryptedNostrCredentials(val iv: ByteArray, val ciphertext: ByteArray) { + fun decryptAndGetCredentials(): Map + fun serialize(): ByteArray + companion object { + const val VERSION: Byte = 1 + fun deserialize(bytes: ByteArray): EncryptedNostrCredentials + fun encrypt(credentials: Map): EncryptedNostrCredentials + } +} +``` + +The read keeps the check that makes the file trustworthy and adds its counterpart: +a `secret` entry must derive the public key it is filed under, as today, and a +`public` entry's map key must be sixty-four hex characters naming a point on the +curve. Either failure is `SerializationError`, because either can only be +corruption. + +Encrypting a public key protects nothing and costs nothing. What it buys is one +read path with one failure classification, and — a small thing, but real — the +list of profiles a user has looked at is a browsing record, and it is at rest +under the same key as everything else about them. + +### Why the file is renamed rather than versioned in place + +A version byte of 2 in `nostr-keys.dat` would be the natural move and it is the +wrong one. An older build's reader throws on an unknown version, `NostrKeyManager` +classifies that as `SerializationError`, and `listIdentities` **returns** on that +result before it publishes anything +([SovereignWalletViewModel.kt:218](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)): +the old build would list no identities at all, seed wallets included. That is the +`seed.dat` failure the nsec plan's appendix rejected a version-4 payload for. A +file the old build does not look for cannot do that to it. + +So: `nostr-credentials.dat`, version 1 of a new file, and the old reader kept as +`LegacyNostrKeysFile` for exactly one caller. + +### Migration + +`NostrCredentialManager.migrateFromNostrKeys(phoenixGlobal)`: if +`nostr-credentials.dat` is absent and `nostr-keys.dat` exists, read the old file, +convert every entry to `Secret`, write the new file through the verified atomic +write, and **delete the old one**. Called once from `listIdentities`, which runs +from `SovereignWalletViewModel.init` before anything else touches either file, so +a read stays a read and the migration is a named step with a test of its own. + +Delete, rather than leave a frozen copy for an older build to find. A copy goes +stale in the one direction that matters: a key the user asks the new build to +*forget* would survive in a file only the old build reads, and come back on a +downgrade. Losing sight of every nsec identity on a downgrade until the next +upgrade is the lesser failure, and [Phase 8](#phase-8--rollout) says so out loud. + +As far as the repository can show, the migration will run for nobody: the library +commit that introduced `nostr-keys.dat` carries no tag and the app has none. It +exists because the repository cannot prove a negative, and because it is thirty +lines. ### Listing @@ -296,59 +340,69 @@ sealed interface StoredIdentity { } ``` -`StoredIdentity.merge` takes a third argument and applies one rule: **a secret wins -over a public key**. A pubkey present in `nostr-keys.dat` or derived from a seed -and also in the list is listed once, as the signing kind, and the list entry is -logged as stale. It cannot happen after the writers below have run to completion, -and it can happen if the process dies between their two writes; the merge is where -the two files are reconciled, and the next write repairs the list. +`StoredIdentity.merge(wallets, credentials)` keeps its two inputs; a `Secret` is a +`NostrSecret` and a `Public` a `NostrPublic`. One entry per pubkey is the file's +own invariant, so there is no precedence between credentials to decide. There is +one between a seed and a credential, and it exists for the one upgrade that has to +span two files (below): a `Public` whose pubkey a seed derives is dropped, with a +log line, and the next write repairs the file. ### The writers ```kotlin object IdentityWriter { … - suspend fun writeNostrPublicKey(log, phoenixGlobal, globalPrefs, publicKey: HexKey, isTorEnabled, customElectrumServer): WriteNostrPublicKeyResult - suspend fun forgetNostrPublicKey(log, phoenixGlobal, id: WalletId, publicKey: HexKey): ForgetNostrPublicKeyResult + suspend fun writeNostrPublicKey(log, phoenixGlobal, globalPrefs, publicKey: HexKey, isTorEnabled, customElectrumServer): WriteNostrCredentialResult + suspend fun forgetNostrCredential(log, phoenixGlobal, id: WalletId, publicKey: HexKey): ForgetNostrCredentialResult } ``` -Same shape as `writeNostrKey` and `forgetNostrKey`: load, refuse a duplicate **by -public key across all three stores**, write, `prepareIdentity`. The Tor and -Electrum preferences mean even less to a read-only identity than to an nsec one; -they are saved for the reason the nsec plan gave — `loadUserPrefsForWallet` is what -creates the file. +`writeNostrKey` stays and writes a `Secret`; `writeNostrPublicKey` writes a +`Public`; both refuse a duplicate by public key against the seeds *and* the +credentials, and both `prepareIdentity` — the Tor and Electrum preferences mean +even less to a read-only identity than to an nsec one, and are saved for the +reason the nsec plan gave. `forgetNostrKey` becomes `forgetNostrCredential` and +removes an entry of either kind; its `NotABareKey` becomes `NotACredential`, which +still means a mnemonic. ### The upgrade rule -`writeNostrKey` refuses a key whose public key is already known. That check learns -one distinction: +`writeNostrKey`'s duplicate check learns one distinction: | the pubkey is already here as | pasting its nsec | pasting its npub | |---|---|---| | a wallet (seed) | `AlreadyExists` | `AlreadyExists` | -| an nsec | `AlreadyExists` | `AlreadyExists` | -| a public key | **upgrade**: write the key, then remove the list entry, `Written(id)` with the id unchanged | `AlreadyExists` | +| a `secret` credential | `AlreadyExists` | `AlreadyExists` | +| a `public` credential | **upgrade**: the entry becomes `secret`, in one write; `Written(id)` with the id unchanged | `AlreadyExists` | -`writeMnemonic` gets the same upgrade for a seed whose nostr key is in the list — -remove the entry; the wallet's id is `hash160(nodeId)` and its preferences are -fresh, which is what a new wallet gets today. +One write, because it is one file. There is no window in which the device holds +both or neither, and nothing to reconcile afterwards — which is the whole reason +this phase is a library change and not a second file. -**The order of the two writes is not a style choice.** Key first, then the list. A -crash between them leaves the pubkey in both files, which `merge` resolves in -favour of the secret; the reverse order would leave it in neither, which is an -identity gone. +The one upgrade that still spans two files is a *recovery phrase* pasted over a +public credential: the seed goes to `seed.dat`, and that file cannot hold anything +else. `writeMnemonic` removes the `public` entry **first**, then writes the seed. +A crash between the two loses the read-only identity — recoverable by pasting the +npub again — rather than leaving one pubkey listed twice under two ids, which is +what the reverse order would do and what the merge rule above is there to catch +if it somehow happens anyway. The wallet's id is `hash160(nodeId)` and its +preferences are fresh, as a new wallet's are today. ### Tests -`StoredIdentityJvmTest` gains: three stores land in one map, each under its own -id; a pubkey in the list and in `nostr-keys.dat` is listed once as `NostrSecret`. +In the library, after the two that exist: `EncryptedNostrCredentialsTest` in +`commonTest`, the layout as literal bytes with one entry of each kind, because the +format is a compatibility contract from the moment it exists; and, in `jvmTest`, a +round trip through the key store, plus the migration — a v1 `nostr-keys.dat` +written by `LegacyNostrKeysFile`'s test fixture, read back as `Secret` entries in +`nostr-credentials.dat`, the old file gone. + +In the app: `StoredIdentityJvmTest` gains a `Public` credential listed as +`NostrPublic`, and a `Public` whose pubkey a seed derives dropped. `IdentityWriterJvmTest` gains: a public key is written once and refused the second -time; its nsec is then accepted, under the same id, and the list no longer holds -it; the npub of an nsec already here is refused; forgetting a public key leaves the -others and takes its preferences. A layout test for the JSON — the version field -and one entry, as literal bytes — sits beside them, because the file is a -compatibility contract from the moment it exists. +time; its nsec is then accepted, under the same id, and the entry is now `secret`; +the npub of an nsec already here is refused; forgetting a credential of either kind +leaves the others and takes its preferences. --- @@ -358,9 +412,9 @@ compatibility contract from the moment it exists. ### Listing and starting -`SovereignWalletViewModel.listIdentities` reads the third store after the second, -with `ReadResult.FileNotFound` as the empty set and the other two failures surfaced -as `ListWalletState.Error`, the way the other two files are handled +`SovereignWalletViewModel.listIdentities` runs the migration, then reads +`nostr-credentials.dat` where it read `nostr-keys.dat`, with the same five results +handled the same way; the change is the type of the map it hands to `merge` ([SovereignWalletViewModel.kt:242](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)). `SovereignWalletStartupScreen` gains its third branch, beside the nsec one @@ -466,7 +520,7 @@ is stored, its `GiftWrapMessage` row is stored, no seal is stored, and nothing throws — `NostrNip17DaoJvmTest` has the fixtures for building the wrap. The listing itself is covered where the nsec plan ended up covering it: `merge`, in `StoredIdentityJvmTest` (Phase 2), and the round trip in Phase 7, which lists -through the view model against a real directory. The nsec plan asked for a +through the view model against a real directory and a real key store. The nsec plan asked for a `SovereignWalletViewModel` listing test of its own and the build did not write one; this plan does not pretend to. @@ -691,10 +745,10 @@ out of the file, preferences deleted, metadata hidden, account rows gone, then t caller re-lists and clears the active identity — and the order is documented on the function: a failure partway leaves the key on disk rather than an identity the selector lists but nothing can open. The sequence is the same for a read-only -identity with the first step swapped. Lift it into a `ForgetIdentity` helper that -takes the identity and dispatches the first step on its kind, returning -`NotABareKey` for a mnemonic one, and have `NostrSecretViewModel` call it. The diff -will show whether that is one function or two. +identity, and since Phase 2 the first step is the same call — `forgetNostrCredential` +removes an entry of either kind. Lift the sequence into a `ForgetIdentity` helper +that takes the identity, returns `NotACredential` for a mnemonic one, and is +called by `NostrSecretViewModel` and the two exits below alike. `ActiveProfileScreen`'s *sign out* then does, for a read-only identity only, what its colour has been promising: a confirmation dialog naming the npub — @@ -745,40 +799,46 @@ for the pubkey is the placeholder, still at `GENESIS_AT`; no key package bundle row; no broadcast request; `identity.business == null`; `identity.canSign` false. **The upgrade.** From the state above, write the nsec of the same pubkey through -`writeNostrKey`: `Written` with the same id; `nostr-keys.dat` holds the key and the -list does not; `signInToProfile` planted nothing new, and the account routes to -`ProfileLoaded` as before. Re-list: one identity, `NostrSecret`, same id, same -metadata. +`writeNostrKey`: `Written` with the same id; the credentials file holds one entry +for the pubkey and it is `secret`; `signInToProfile` planted nothing new, and the +account routes to `ProfileLoaded` as before. Re-list: one identity, `NostrSecret`, +same id, same metadata. --- ## Phase 8 — rollout -**No library change.** Every file this plan touches is in `composeApp`, so there is -no submodule pointer to bump and no tag to cut. That is the one place this rollout -is simpler than the nsec plan's, and it is because Phase 2 chose the app. +**Library first, and on the tag the nsec work still owes.** Phase 2 is a commit +on the submodule, reaching the app as a pointer bump in the Phase 3 commit. The +library commit that introduced `nostr-keys.dat` is untagged and sits on a remote +branch called `detached`; the nsec plan says it has to be pushed and tagged before +any of that work leaves the machine, and Phase 2 goes out on the same tag, so that +no JitPack consumer ever sees the v1 file without the reader that migrates it. **Phase 1 ships alone.** A type change with two `require`s and one exhaustive `when`; if the release after it behaves differently, the cause is in one diff. -**Phases 2 through 5 ship together.** A build with the store but not the screen is -harmless; a build with the screen but not the store strands the user at +**Phases 3 through 5 ship together.** A build with the file but not the screen is +harmless; a build with the screen but not the file strands the user at "Initializing…"; and a build with both but not Phase 5 is the thing this plan is for — an identity that looks signed in and offers "New chat". Phase 6 can follow by a release: without it a read-only identity has no sign out and no not-found exit, which is a dead end but not a lie. -**Old builds and the new file.** A build without Phase 3 does not read -`nostr-public-keys.json` and does not know it exists. A downgrade loses sight of a -read-only identity and nothing else; `seed.dat` and `nostr-keys.dat` are untouched -throughout. +**Old builds and the new file.** A build before Phase 3 does not read +`nostr-credentials.dat` and does not know it exists. If it also predates the nsec +work it is unaffected in every way. If it is a build *with* `nostr-keys.dat` and +the migration has run on this device, that file is gone and the build lists no +nsec identities until the next upgrade — the failure Phase 2 chose over a +forgotten key coming back, and the one line of this rollout worth a release note. +`seed.dat` is untouched throughout. ## Estimate | phase | work | days | blocked by | |---|---|---|---| | 1 | a key the identity may not have | 0.5–1 | — | -| 2 | the list, its writers, the upgrade rule | 1 | 1 | +| 2 | the credentials file, migration, the writers, the upgrade rule (library + app) | 1.5 | 1 | | 3 | listing, starting, the pumps, the DAO guard | 1 | 2 | | 4 | the sign-in screen; an idempotent sign-in | 0.5–1 | 3 | | 5 | the capability, the inventory, the Messages tab | 1 | 1 to compile, 3 to see | @@ -819,15 +879,33 @@ added to both. ## Appendix — what was considered and rejected -### A sentinel entry in `nostr-keys.dat` +### A plaintext sibling file for public keys -An empty-string value, or a zero key, under the pubkey. One file, one store, one -`merge`. Rejected because the file's contract is the reason it is trustworthy: +This plan's first design: `nostr-public-keys.json` beside the two `.dat` files, +unencrypted because a public key is public, written through the same atomic +helper, and app-side — no library change, no tag. It was rejected in review, and +rightly. The upgrade became two writes across two files with a crash window +between them and a "secret wins" rule in `merge` to repair the window; forget +became two paths; and the advantage that paid for all of that was worth less than +it looked, because the library commit that introduced `nostr-keys.dat` is itself +untagged, so a credentials format rides the tag that work already owes. One file, +one entry per pubkey, one write. + +### A sentinel entry in the version-1 format + +An empty-string value, or a zero key, under the pubkey, in `nostr-keys.dat` as it +is. Rejected because the file's contract is the reason it is trustworthy: `EncryptedNostrKeys` refuses an entry whose key does not derive its pubkey, and a -sentinel is exactly such an entry. A second version byte for a file that exists to -hold secrets, so that it can hold a non-secret, is the wrong direction; and it -would be a library change, with the tag and the JitPack consumers that come with -one. +sentinel is exactly such an entry. A typed credential is what a sentinel is trying +to be, and the file's doc had already reserved a version for it. + +### Bumping the version in place + +Version byte 2 in `nostr-keys.dat`, with the typed entries. Rejected in +[Phase 2](#why-the-file-is-renamed-rather-than-versioned-in-place): an older +build's `listIdentities` returns on `SerializationError` before it publishes +anything, so the old build would list no identities at all, seed wallets included. +A new file name is invisible to a build that does not know it. ### A fake private key @@ -866,5 +944,5 @@ be read. The pumps that run are the ones with something to do. `ReadOnlyIdentity(publicKey)`, listed by the repository. Rejected because the startup listing is built from the key stores by a view model that has no repository, and because the nsec plan's listing test exists to catch two stores -disagreeing about what an id is; a third store in a different layer, read at a -different time, is a third opinion. +disagreeing about what an id is; a store in a different layer, read at a different +time, is a second opinion where the credentials file is meant to be the only one. From cf43973b3657735cd7fd95ad6b2224a248bf7e26 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:14:29 +0200 Subject: [PATCH 29/71] feat(identity): a key the identity may not have Phase 1 of docs/npub-sign-in.md. A type change and nothing a user can see. Identity.nostrPrivateKey becomes nullable and nostrPublicKey becomes a constructor field, because the kind this plan adds -- NostrPublic, a bare public key -- has nothing to derive a pubkey from. A data class would let the two disagree, so two checks in init refuse an identity whose kind and key disagree about whether there is one, and one whose key does not derive the public key it was given. Identity.signing derives the pubkey and refuses the read-only kind; Identity.readOnly builds the other; nothing else constructs one now. The id of a read-only identity is toWalletId() of the x-only key -- the same id its nsec would have -- which is what will let a read-only identity become a signing one and keep its preferences. canSign is a property of the identity rather than nostrPrivateKey != null at each site, so that a signer with no local key has one place to answer. Every reader of the key already reached it through ?. on a nullable identity, so the compiler forces nothing here; the sites that need a decision are the plan's Phase 5, found by reading. The one site that did change is KeyRecoveryScreen, whose two whens branched on NostrSecret with an else that meant "mnemonic": both are exhaustive now, so the new kind is not handed the phrase option by default. Tests: NavigationIdentityRoutingJvmTest gains a read-only fixture with the same id and pubkey as the secret one, the routing case for it (by its account, like any other), and the three refusals plus the factory's own. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@15766596e463d4c7f57b8a636ac9be9eb83b1272 --- .../press/mantra/compose/identity/Identity.kt | 94 ++++++++++++-- .../ui/composable/KeyRecoveryScreen.kt | 22 +++- .../SovereignWalletStartupScreen.kt | 2 +- .../ui/view/model/SovereignWalletViewModel.kt | 2 +- .../identity/NsecRestoreRoundTripJvmTest.kt | 2 +- .../model/NavigationIdentityRoutingJvmTest.kt | 118 +++++++++++++++++- .../view/model/NostrSecretViewModelJvmTest.kt | 2 +- 7 files changed, 221 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt index 7a920d38..9ed2737e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -7,6 +7,7 @@ import fr.acinq.bitcoin.XonlyPublicKey import fr.acinq.bitcoin.byteVector import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.utils.preferences.InternalPrefs import fr.acinq.phoenix.utils.preferences.UserPrefs @@ -16,7 +17,8 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs * The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at * `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the * key and a wallet; a bare key yields nothing further, so an identity made from - * one can never grow a node behind it. See docs/nsec-sign-in.md. + * one can never grow a node behind it; and a bare public key yields nothing at all, + * not even a signature. See docs/nsec-sign-in.md and docs/npub-sign-in.md. */ enum class IdentityKind { /** Twelve words. The nostr key is derived from them, and so is a wallet. */ @@ -24,10 +26,14 @@ enum class IdentityKind { /** A bare nostr secret. Nothing else can be derived from it. */ NostrSecret, + + /** A bare nostr public key. Nothing can be signed, opened or derived from it. */ + NostrPublic, } /** - * The signing identity the app is running as. + * The signing identity the app is running as -- or, for [IdentityKind.NostrPublic], + * the identity it is looking at. * * This is what every consumer of "the active user" reads, in place of reaching * through the wallet -- `business.walletManager.keyManager.value.nostrPrivateKey()` @@ -37,32 +43,96 @@ enum class IdentityKind { * Marmot bundle password is `hash160` of it. None of them can tell where it came * from, and after this type none of them need to. * + * [nostrPublicKey] is a field rather than a derivation because a read-only identity + * has nothing to derive it from. The two checks in `init` keep the field and the key + * from disagreeing, which a data class would otherwise happily allow; [signing] and + * [readOnly] are the two ways to get one built without tripping them. + * * @param id keys the per-identity preference files and the wallet metadata. For a * [IdentityKind.Mnemonic] identity it is the hash160 of the node id, as it always - * was; for a [IdentityKind.NostrSecret] identity it is the hash160 of the x-only - * public key ([toWalletId]). Same shape, so nothing downstream can tell the kinds - * apart by the id -- the kind is here, where it can be asked. + * was; for the two bare kinds it is the hash160 of the x-only public key + * ([toWalletId]) -- the *same* id whether the device holds the secret or only the + * public key, which is what lets a read-only identity become a signing one and keep + * its preferences. Same shape as a wallet's, so nothing downstream can tell the + * kinds apart by the id -- the kind is here, where it can be asked. + * @param nostrPrivateKey null only for [IdentityKind.NostrPublic]. * @param business the running node. Non-null only for [IdentityKind.Mnemonic]. */ data class Identity( val id: WalletId, val kind: IdentityKind, - val nostrPrivateKey: PrivateKey, - val userPrefs: UserPrefs, - val internalPrefs: InternalPrefs, - val business: PhoenixBusiness?, -) { /** - * X-only, hex -- the form every nostr call site wants. Computed once. + * X-only, hex -- the form every nostr call site wants. * * Not `publicKey().toHex()`: that is the 33-byte compressed encoding, sixty-six * hex characters, and it is what the library's own `nostrPublicKey()` returns. * Nostr keys are the 32-byte x coordinate. */ - val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex() + val nostrPublicKey: HexKey, + val nostrPrivateKey: PrivateKey?, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + val business: PhoenixBusiness?, +) { + /** + * What the notary, the pumps and every write entrance ask. + * + * A property of the identity rather than `nostrPrivateKey != null` repeated at each + * site, so that a signer that holds no local key -- a remote one, some day -- has + * one place to answer *true*. + */ + val canSign: Boolean get() = nostrPrivateKey != null + + init { + require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) { + "identity kind $kind and its key disagree about whether there is one" + } + require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) { + "identity key does not derive the public key it was given" + } + } /** The key must never reach a log. `data class` would print it. */ override fun toString(): String = "Identity(id=$id, kind=$kind, key=)" + + companion object { + /** An identity that holds its secret: the public key is derived, never supplied. */ + fun signing( + id: WalletId, + kind: IdentityKind, + nostrPrivateKey: PrivateKey, + userPrefs: UserPrefs, + internalPrefs: InternalPrefs, + business: PhoenixBusiness?, + ): Identity { + require(kind != IdentityKind.NostrPublic) { "a read-only identity has no secret to sign with" } + return Identity( + id = id, + kind = kind, + nostrPublicKey = nostrPrivateKey.nostrPublicKeyHex(), + nostrPrivateKey = nostrPrivateKey, + userPrefs = userPrefs, + internalPrefs = internalPrefs, + business = business, + ) + } + + /** An identity the device holds only the public key of. No secret, no node. */ + fun readOnly( + id: WalletId, + nostrPublicKey: HexKey, + userPrefs: UserPrefs, + internalPrefs: InternalPrefs, + ): Identity = Identity( + id = id, + kind = IdentityKind.NostrPublic, + nostrPublicKey = nostrPublicKey, + nostrPrivateKey = null, + userPrefs = userPrefs, + internalPrefs = internalPrefs, + business = null, + ) + } } /** diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index b70a798c..551b594b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -129,10 +129,17 @@ fun KeyRecoveryScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - // A bare-key identity has no coins to speak of. + // A bare-key identity has no coins to speak of. Exhaustive rather than + // `else`: the `else` here used to mean "mnemonic", and a kind added later + // would have fallen into it and been promised coins. A read-only identity + // is never offered this screen (docs/npub-sign-in.md, Phase 5); it reads + // as the bare-key kind so that, if it ever is, the sentence is at least + // not about a wallet. text = when (activeIdentity?.kind) { - IdentityKind.NostrSecret -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet) - else -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they) + IdentityKind.NostrSecret, + IdentityKind.NostrPublic -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet) + IdentityKind.Mnemonic, + null -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they) }, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center @@ -174,7 +181,14 @@ fun KeyRecoveryScreen( } ) - else -> KeyRecoveryOption( + // Nothing to recover: the device holds no secret for this identity. The + // row that leads here is hidden for the kind, so this arm is not reached; + // it exists so that the `when` says so rather than handing a read-only + // identity the phrase option through an `else`. + IdentityKind.NostrPublic -> Unit + + IdentityKind.Mnemonic, + null -> KeyRecoveryOption( icon = Icons.Default.Spellcheck, title = stringResource(Res.string.recovery_phrase), description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 9bd960f7..6a4ab6e1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -189,7 +189,7 @@ fun SovereignWalletStartupScreen( // recomposes into the `activeIdentity != null` branch // above, which is what reports the startup. sovereignWalletViewModel.setActiveIdentity( - Identity( + Identity.signing( id = identity.id, kind = IdentityKind.NostrSecret, nostrPrivateKey = identity.privateKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 9997f884..5f6e36ee 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -158,7 +158,7 @@ class SovereignWalletViewModel( val dataStoreManager = DataStoreManager(business) setActiveIdentity( - Identity( + Identity.signing( id = walletId, kind = IdentityKind.Mnemonic, nostrPrivateKey = keyManager.nostrPrivateKey(), diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt index 01c30bc6..97f23166 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt @@ -126,7 +126,7 @@ class NsecRestoreRoundTripJvmTest { // 3. What the startup screen's NostrSecret branch does: an identity, no node. val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) - val identity = Identity( + val identity = Identity.signing( id = stored.id, kind = IdentityKind.NostrSecret, nostrPrivateKey = stored.privateKey, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt index ce80da88..d49b2187 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.utils.preferences.InternalPrefs import fr.acinq.phoenix.utils.preferences.UserPrefs import kotlinx.coroutines.CoroutineScope @@ -31,6 +32,10 @@ import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStart import press.mantra.compose.ui.view.state.NavigationUIState import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Instant /** @@ -65,7 +70,7 @@ class NavigationIdentityRoutingJvmTest { temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() } - private fun nostrSecretIdentity() = Identity( + private fun nostrSecretIdentity() = Identity.signing( id = privateKey.publicKey().xOnly().toWalletId(), kind = IdentityKind.NostrSecret, nostrPrivateKey = privateKey, @@ -74,6 +79,14 @@ class NavigationIdentityRoutingJvmTest { business = null, ) + /** The same key, held as a public key only: same id, no secret, no node. */ + private fun readOnlyIdentity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + /** Everything `observeProfile` reads, and nothing else; every other member throws. */ private class Device( private val accounts: List @@ -176,4 +189,107 @@ class NavigationIdentityRoutingJvmTest { viewModel.navigationUIState.value, ) } + + /** + * A read-only identity is the same identity minus the secret: same public key, same + * id, and the machine routes it by its account exactly as it routes the nsec. + */ + @Test + fun `a read-only identity has the id and public key its secret would, and cannot sign`() { + val readOnly = readOnlyIdentity() + val signing = nostrSecretIdentity() + + assertEquals(signing.nostrPublicKey, readOnly.nostrPublicKey) + assertEquals(signing.id, readOnly.id) + assertNull(readOnly.nostrPrivateKey) + assertNull(readOnly.business) + assertFalse(readOnly.canSign) + assertTrue(signing.canSign) + assertEquals(IdentityKind.NostrPublic, readOnly.kind) + } + + @Test + fun `a read-only identity is routed by its account, like any other`() { + val identity = readOnlyIdentity() + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(identity.nostrPublicKey))), + scope = scope, + ) + + try { + val destination = runBlocking { + withTimeout(15_000) { viewModel.navigationUIState.first { it != stranded } } + } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + destination, + "a read-only identity was not routed by its account", + ) + } finally { + scope.cancel() + } + } + + /** + * The two checks that make [Identity.nostrPublicKey] safe to be a field. Without + * them a data class would accept a key filed under someone else's public key, or a + * "read-only" identity that could in fact sign. + */ + @Test + fun `an identity whose kind and key disagree is refused, and so is one whose key does not derive its public key`() { + val prefs = UserPrefs(prefsStore("user")) + val internal = InternalPrefs(prefsStore("internal")) + + // A secret filed under a public key it does not derive. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPublicKey = "a".repeat(64), + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // A read-only kind that nonetheless carries a key. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrPublic, + nostrPublicKey = privateKey.nostrPublicKeyHex(), + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // A signing kind with no key to sign with. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPublicKey = privateKey.nostrPublicKeyHex(), + nostrPrivateKey = null, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // And the factory refuses to build a "signing" read-only identity at all. + assertFailsWith { + Identity.signing( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrPublic, + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt index a6bd120c..1e8c8de6 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt @@ -68,7 +68,7 @@ class NostrSecretViewModelJvmTest { temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() } - private fun identity() = Identity( + private fun identity() = Identity.signing( id = privateKey.publicKey().xOnly().toWalletId(), kind = IdentityKind.NostrSecret, nostrPrivateKey = privateKey, From d5bd24836426abc186b4b4e849b6e2db4f27fea5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:25:18 +0200 Subject: [PATCH 30/71] feat(identity): one credentials file, with a read-only kind and the upgrade in one write Phase 2 of docs/npub-sign-in.md: the library half is lightning-kmp-app 01962f3 (claude/nostr-credentials), bumped in here; this is the app half. nostr-keys.dat becomes nostr-credentials.dat, one typed entry per public key -- a secret, or only the public key -- so that a profile signed in to read-only and the same profile with its nsec pasted later are one entry in one file. StoredIdentity gains NostrPublic, whose id is the one its secret would have (hash160 of the x-only key), and merge reads the typed map. It keeps the one precedence it needs: a public entry for a key a seed already derives lists the wallet only, which is the state the seed writer's two-file upgrade can leave behind if it dies between its writes. A secret for a seed's key is still listed twice, as before -- that is a duplicate the writers refuse, not a state the listing hides. IdentityWriter: writeNostrPublicKey beside writeNostrKey, both refusing a duplicate by public key against the seeds and the credentials, with the one exception that is the point of the file -- the nsec of a key held read-only is not a duplicate, since the device does not have that secret. writeNostrKey replaces the public entry with a secret one in a single write, under the id it already had, so the read-only identity's preferences are the ones the signing identity keeps. writeMnemonic has to span two files for the same upgrade and removes the credential first, then writes the seed: a crash between the two loses the read-only identity, which the npub pasted again restores, rather than listing one npub twice under two ids. forgetNostrKey becomes forgetNostrCredential and removes an entry of either kind; NotABareKey becomes NotACredential and still means a mnemonic. The startup screen's third branch is here rather than in Phase 3 because StoredIdentity is sealed and the compiler asked for it: a read-only identity is active the moment it is read, as the nsec one is. NostrSecretViewModel reads the secret entry and answers a public one with "no key for this identity", which the screen it belongs to will never show once Phase 5 hides the row. Tests: the writer's upgrade in both directions -- the nsec of a read-only key accepted under the same id, the npub of a secret refused -- and forget of either kind; merge listing all three kinds, the shared id of a public key and its secret, and the seed-over-public precedence. Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library's master as 84cc44c with an identical tree. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@5efeae769f423181cceade4fcb958449082ff5d0 --- .../mantra/compose/identity/IdentityWriter.kt | 184 ++++++++++++------ .../mantra/compose/identity/StoredIdentity.kt | 69 +++++-- .../ui/composable/NostrSecretScreen.kt | 4 +- .../compose/ui/composable/SignInScreen.kt | 4 +- .../SovereignWalletStartupScreen.kt | 14 ++ .../ui/composable/navigation/MantraNavHost.kt | 2 +- .../ui/view/model/NostrSecretViewModel.kt | 41 ++-- .../ui/view/model/SignInToProfileViewModel.kt | 10 +- .../ui/view/model/SovereignWalletViewModel.kt | 46 +++-- .../compose/identity/IdentityWriterJvmTest.kt | 109 ++++++++--- .../identity/NsecRestoreRoundTripJvmTest.kt | 6 +- .../compose/identity/StoredIdentityJvmTest.kt | 44 ++++- .../view/model/NostrSecretViewModelJvmTest.kt | 12 +- .../model/SignInToProfileViewModelJvmTest.kt | 6 +- lightning-kmp-app | 2 +- 15 files changed, 399 insertions(+), 154 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt index 40ff06dc..5fcea706 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -11,12 +11,13 @@ import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager import fr.acinq.phoenix.managers.SeedManager import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.managers.nostrPublicKeyHex -import fr.acinq.phoenix.security.EncryptedNostrKeys +import fr.acinq.phoenix.security.EncryptedNostrCredentials import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.security.NostrCredential import fr.acinq.phoenix.utils.preferences.GlobalPrefs import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -28,8 +29,8 @@ import press.mantra.compose.AppVersion import press.mantra.compose.ui.view.model.WritingSeedState /** - * Puts a new identity's secret on disk, one function per kind, and prepares the - * per-identity preferences both kinds read. + * Puts a new identity on disk, one function per kind, and prepares the per-identity + * preferences every kind reads. * * [writeMnemonic] was an `expect` with three byte-identical actuals -- android, jvm, ios -- * using nothing but commonMain: SeedManager, EncryptedSeed, LocalKeyManager, @@ -37,19 +38,24 @@ import press.mantra.compose.ui.view.model.WritingSeedState * differ and nothing does now, so it is one function here, and the second writer sits * next to it rather than being triplicated in turn. * - * Both refuse a duplicate **by nostr public key**, not only by id. A wallet and an + * All three refuse a duplicate **by nostr public key**, not only by id. A wallet and an * imported key can be the same npub under different ids -- one is hash160 of the node * key, the other hash160 of the nostr key -- and the database is keyed by pubkey, so two * identities for one pubkey would share every row and disagree about which is active. + * + * With one exception, which is the point of the credentials file: a public key already + * here read-only is not a duplicate of the secret that signs as it. The device does not + * have that secret, and refusing it would be false. [writeNostrKey] replaces the entry + * in one write; [writeMnemonic] has to span two files and says which goes first. */ object IdentityWriter { - /** The nostr public keys of every identity already on this device, or null if a store could not be read. */ - private fun knownNostrPublicKeys(phoenixGlobal: PhoenixGlobal): Set? { - val wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null - val nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null - return StoredIdentity.merge(wallets, nostrKeys).values.map { it.nostrPublicKey }.toSet() - } + /** What the seed store says, or null if it could not be read. */ + private fun seedPublicKeys(phoenixGlobal: PhoenixGlobal): Set? = + SeedManager.loadAndDecryptOrNull(phoenixGlobal) + ?.values + ?.map { StoredIdentity.nostrPublicKeyOf(it.words) } + ?.toSet() /** * Creates the preference files a new id needs and records the choices made before @@ -107,8 +113,8 @@ object IdentityWriter { val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { it.key to it.value.words }?.toMap() - // Null when nostr-keys.dat exists and cannot be read; an empty map when it is absent. - val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + // Null when nostr-credentials.dat exists and cannot be read; an empty map when it is absent. + val existingCredentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) @@ -116,7 +122,7 @@ object IdentityWriter { val newNostrPublicKey = keyManager.nostrPrivateKey().nostrPublicKeyHex() when { - existingSeeds == null || existingNostrKeys == null -> { + existingSeeds == null || existingCredentials == null -> { log.e("could not load the existing seed map, aborting...") onWritingSeedError.invoke( WritingSeedState.Error.CannotLoadSeedMap @@ -130,7 +136,7 @@ object IdentityWriter { ) return@launch } - existingNostrKeys.containsKey(newNostrPublicKey) -> { + existingCredentials[newNostrPublicKey] is NostrCredential.Secret -> { // The same npub is already here as a bare key. The id check above cannot // see that -- different hash, different key -- so it is asked by pubkey. log.i("attempting to import a seed whose nostr key is already here, aborting...") @@ -140,6 +146,18 @@ object IdentityWriter { return@launch } else -> { + if (existingCredentials[newNostrPublicKey] is NostrCredential.Public) { + // The npub is here read-only, and the words that sign as it have just + // been pasted: an upgrade, across two files. The credential goes + // first, then the seed. A crash between the two loses the read-only + // identity, which the npub pasted again restores; the reverse order + // would leave one npub listed twice under two ids. + log.i("the seed's nostr key is here read-only; replacing it with the wallet") + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(existingCredentials - newNostrPublicKey), + ) + } val newSeedMap = existingSeeds + (newWalletId to mnemonics) val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) @@ -160,21 +178,28 @@ object IdentityWriter { } } - sealed class WriteNostrKeyResult { - data class Written(val id: WalletId) : WriteNostrKeyResult() + sealed class WriteNostrCredentialResult { + data class Written(val id: WalletId) : WriteNostrCredentialResult() - /** An identity with this nostr public key is already on the device, as a wallet or as a key. */ - data object AlreadyExists : WriteNostrKeyResult() + /** + * An identity with this nostr public key is already on the device and holds at least + * what was pasted: a wallet or a secret, for either input; a public key, for an npub. + */ + data object AlreadyExists : WriteNostrCredentialResult() /** One of the two stores exists and could not be read; nothing was written. */ - data object CannotLoadKeys : WriteNostrKeyResult() + data object CannotLoadKeys : WriteNostrCredentialResult() } /** - * Adds [privateKey] to `nostr-keys.dat` and prepares its preferences. Suspending and - * result-shaped rather than callback-shaped: nothing was built around a state machine - * for it, and the caller is a coroutine already. Throws on an I/O or key store failure - * during the write, as [writeMnemonic]'s handler would have caught. + * Adds [privateKey] to `nostr-credentials.dat` as a secret and prepares its + * preferences. Suspending and result-shaped rather than callback-shaped: nothing was + * built around a state machine for it, and the caller is a coroutine already. Throws + * on an I/O or key store failure during the write, as [writeMnemonic]'s handler would + * have caught. + * + * A public entry for the same key is the one thing that is not a duplicate: the entry + * becomes a secret one, in a single write, under the id it already had. */ suspend fun writeNostrKey( log: Logger, @@ -183,65 +208,112 @@ object IdentityWriter { privateKey: PrivateKey, isTorEnabled: Boolean, customElectrumServer: ElectrumConfig.Custom?, - ): WriteNostrKeyResult { + ): WriteNostrCredentialResult { log.d("writing nostr key to disk...") val stored = StoredIdentity.nostrSecret(privateKey) - val known = knownNostrPublicKeys(phoenixGlobal) - val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) - if (known == null || existingNostrKeys == null) { + val seeds = seedPublicKeys(phoenixGlobal) + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") - return WriteNostrKeyResult.CannotLoadKeys + return WriteNostrCredentialResult.CannotLoadKeys } - if (stored.nostrPublicKey in known) { + if (stored.nostrPublicKey in seeds || existing[stored.nostrPublicKey] is NostrCredential.Secret) { log.i("attempting to import a nostr key that is already here, aborting...") - return WriteNostrKeyResult.AlreadyExists + return WriteNostrCredentialResult.AlreadyExists } + val upgrading = existing[stored.nostrPublicKey] is NostrCredential.Public - val encrypted = EncryptedNostrKeys.encrypt(existingNostrKeys + (stored.nostrPublicKey to privateKey)) - NostrKeyManager.writeToDisk(phoenixGlobal, encrypted) - log.i("successfully imported nostr key for identity=${stored.id}") + val encrypted = EncryptedNostrCredentials.encrypt( + existing + (stored.nostrPublicKey to NostrCredential.Secret(privateKey)) + ) + NostrCredentialManager.writeToDisk(phoenixGlobal, encrypted) + if (upgrading) { + log.i("read-only identity=${stored.id} now holds its key") + } else { + log.i("successfully imported nostr key for identity=${stored.id}") + } prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) - return WriteNostrKeyResult.Written(stored.id) - } - - sealed class ForgetNostrKeyResult { - data object Forgotten : ForgetNostrKeyResult() - - /** The key is not in `nostr-keys.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */ - data object NotABareKey : ForgetNostrKeyResult() - - data object CannotLoadKeys : ForgetNostrKeyResult() + return WriteNostrCredentialResult.Written(stored.id) } /** - * The inverse of [writeNostrKey]: removes the key from `nostr-keys.dat` and deletes - * the identity's preference files. The profile stays on the relays, and the same key - * can be signed in again. Only for a bare key -- see [ForgetNostrKeyResult.NotABareKey]. + * Adds [nostrPublicKey] to `nostr-credentials.dat` as a public entry -- an identity + * the device can look at and not sign as -- and prepares its preferences. Refused if + * the device already holds anything at all for the key, since anything is more than + * this. */ - suspend fun forgetNostrKey( + suspend fun writeNostrPublicKey( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + nostrPublicKey: HexKey, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ): WriteNostrCredentialResult { + log.d("writing nostr public key to disk...") + val stored = StoredIdentity.nostrPublic(nostrPublicKey) + + val seeds = seedPublicKeys(phoenixGlobal) + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + if (seeds == null || existing == null) { + log.e("could not load the existing keys, aborting...") + return WriteNostrCredentialResult.CannotLoadKeys + } + if (stored.nostrPublicKey in seeds || existing.containsKey(stored.nostrPublicKey)) { + log.i("attempting to add a public key the device already holds something for, aborting...") + return WriteNostrCredentialResult.AlreadyExists + } + + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(existing + (stored.nostrPublicKey to NostrCredential.Public)), + ) + log.i("added read-only identity=${stored.id}") + + prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) + + return WriteNostrCredentialResult.Written(stored.id) + } + + sealed class ForgetNostrCredentialResult { + data object Forgotten : ForgetNostrCredentialResult() + + /** The key is not in `nostr-credentials.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */ + data object NotACredential : ForgetNostrCredentialResult() + + data object CannotLoadKeys : ForgetNostrCredentialResult() + } + + /** + * The inverse of [writeNostrKey] and [writeNostrPublicKey] alike: removes the entry, + * whichever kind it is, from `nostr-credentials.dat` and deletes the identity's + * preference files. The profile stays on the relays, and the same key can be signed + * in again. Only for a credential -- see [ForgetNostrCredentialResult.NotACredential]. + */ + suspend fun forgetNostrCredential( log: Logger, phoenixGlobal: PhoenixGlobal, id: WalletId, nostrPublicKey: HexKey, - ): ForgetNostrKeyResult { - val existing = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + ): ForgetNostrCredentialResult { + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (existing == null) { log.e("could not load the existing keys, aborting...") - return ForgetNostrKeyResult.CannotLoadKeys + return ForgetNostrCredentialResult.CannotLoadKeys } if (!existing.containsKey(nostrPublicKey)) { - log.i("asked to forget a key that is not a bare key on this device") - return ForgetNostrKeyResult.NotABareKey + log.i("asked to forget a key that is not a credential on this device") + return ForgetNostrCredentialResult.NotACredential } - NostrKeyManager.writeToDisk(phoenixGlobal, EncryptedNostrKeys.encrypt(existing - nostrPublicKey)) - log.i("forgot nostr key for identity=$id") + NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(existing - nostrPublicKey)) + log.i("forgot nostr credential for identity=$id") DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(id) - return ForgetNostrKeyResult.Forgotten + return ForgetNostrCredentialResult.Forgotten } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt index 6ca37701..ec309a7d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt @@ -1,5 +1,6 @@ package press.mantra.compose.identity +import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.MnemonicCode @@ -12,14 +13,16 @@ import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.NostrCredential /** - * An identity this device holds the secret for, as listed before any of them is started. + * An identity this device holds something for, as listed before any of them is started. * - * Two stores feed this -- `seed.dat` for wallets, `nostr-keys.dat` for bare keys -- and - * the startup screen wants one list. The nostr public key is on both kinds because it - * is the one thing they have in common and the one thing a duplicate check has to - * compare: a wallet and an imported key can be the *same* npub under different ids. + * Two stores feed this -- `seed.dat` for wallets, `nostr-credentials.dat` for bare keys + * and bare public keys -- and the startup screen wants one list. The nostr public key + * is on every kind because it is the one thing they have in common and the one thing a + * duplicate check has to compare: a wallet and an imported key can be the *same* npub + * under different ids. * * On what this holds: `SovereignWalletViewModel.availableWallets` has always carried * decrypted words for the view model's life, because starting the node needs them. @@ -49,6 +52,17 @@ sealed interface StoredIdentity { override fun toString(): String = "StoredIdentity.NostrSecret(id=$id, npub=$nostrPublicKey, key=)" } + /** + * A public key the device holds nothing else for. Its id is the one its secret would + * have, so that pasting the nsec later keeps the preferences and metadata. + */ + data class NostrPublic( + override val id: WalletId, + override val nostrPublicKey: HexKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrPublic + } + companion object { /** * The nostr public key a wallet's words derive. `SeedManager.loadAndDecrypt` has @@ -73,24 +87,53 @@ sealed interface StoredIdentity { ) } + fun nostrPublic(publicKeyHex: HexKey): NostrPublic = NostrPublic( + id = publicKeyHex.toXonlyPublicKey().toWalletId(), + nostrPublicKey = publicKeyHex, + ) + /** - * One map from the two stores. [nostrKeys] is keyed by x-only public key hex, as - * `nostr-keys.dat` is; the id of each is derived from that key, so a stored key - * whose map key disagrees with its own public key is dropped here too, though - * `EncryptedNostrKeys` refuses such a file before it gets this far. + * One map from the two stores. [credentials] is keyed by x-only public key hex, as + * `nostr-credentials.dat` is; a secret's id is derived from its own key, so a + * stored secret whose map key disagrees with its public key is dropped here too, + * though `EncryptedNostrCredentials` refuses such a file before it gets this far. + * + * One entry per public key is the credentials file's own invariant, so there is no + * precedence between credentials to decide. There is one between a seed and a + * credential, for the one upgrade that has to span both files -- a recovery phrase + * pasted over a public credential, which `IdentityWriter.writeMnemonic` performs as + * two writes: a `Public` whose public key a seed derives is dropped, with a log line, + * and the next write repairs the file. A *secret* for a seed's key is kept, as it + * always was: two ids for one npub is a duplicate the writers refuse, not a state + * the listing hides. */ fun merge( wallets: Map, - nostrKeys: Map, + credentials: Map, ): Map { val merged = LinkedHashMap() wallets.values.forEach { userWallet -> merged[userWallet.walletId] = mnemonic(userWallet) } - nostrKeys.forEach { (publicKeyHex, privateKey) -> - val stored = nostrSecret(privateKey) - if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored + val seedPublicKeys = merged.values.map { it.nostrPublicKey }.toSet() + credentials.forEach { (publicKeyHex, credential) -> + when (credential) { + is NostrCredential.Secret -> { + val stored = nostrSecret(credential.privateKey) + if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored + } + is NostrCredential.Public -> { + if (publicKeyHex in seedPublicKeys) { + log.w { "public credential for a key a seed already derives; listing the wallet only" } + } else { + val stored = runCatching { nostrPublic(publicKeyHex) }.getOrNull() + if (stored != null) merged[stored.id] = stored + } + } + } } return merged } + + private val log = Logger.withTag("StoredIdentity") } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt index bf04707a..705261a7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt @@ -104,7 +104,7 @@ fun NostrSecretScreen( phoenixGlobal: PhoenixGlobal, activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, - forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, hideIdentityMetadata: suspend (Identity) -> Unit, onNavigateBack: () -> Unit, onForgotten: () -> Unit, @@ -114,7 +114,7 @@ fun NostrSecretScreen( phoenixGlobal = phoenixGlobal, activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, - forgetNostrKey = forgetNostrKey, + forgetNostrCredential = forgetNostrCredential, hideIdentityMetadata = hideIdentityMetadata, ) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index 8dac4889..e66ecf99 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -92,7 +92,7 @@ import press.mantra.compose.ui.view.state.SignInToProfileUIState @Composable fun SignInToProfileScreen( nostrRepository: NostrRepository, - writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -302,7 +302,7 @@ private fun SignInToProfileScreenPreview() { ) { SignInToProfileScreen( nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - writeNostrKey = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }, + writeNostrKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, onNavigateBack = {}, onSignedIn = {}, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 6a4ab6e1..32297e2b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -199,6 +199,20 @@ fun SovereignWalletStartupScreen( ) ) } + is StoredIdentity.NostrPublic -> { + // Nothing to start and nothing to decrypt: the + // public key is the whole of what the device + // holds, and the identity is active the moment + // it is read, as the nsec branch above is. + sovereignWalletViewModel.setActiveIdentity( + Identity.readOnly( + id = identity.id, + nostrPublicKey = identity.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + ) + ) + } } loadingIdentity = null }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 532f3200..5d83c85a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -909,7 +909,7 @@ fun MantraNavHost( phoenixGlobal = phoenixGlobal, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, - forgetNostrKey = { identity -> sovereignWalletViewModel.forgetNostrKey(identity) }, + forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, onNavigateBack = { navController.popBackStack() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt index 6662236e..ff121980 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt @@ -7,8 +7,9 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.DecryptNostrKeysResult -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.data.DecryptNostrCredentialsResult +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.security.NostrCredential import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -52,7 +53,7 @@ class NostrSecretViewModel( val phoenixGlobal: PhoenixGlobal, val activeIdentityStateFlow: StateFlow, private val nostrRepository: NostrRepository, - private val forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + private val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, private val hideIdentityMetadata: suspend (Identity) -> Unit, ) : ViewModel() { @@ -103,25 +104,27 @@ class NostrSecretViewModel( // Keystore-backed, and it reads a file: it blocks, so it cannot run on the main thread. val result = withContext(Dispatchers.IO) { - NostrKeyManager.loadAndDecrypt(phoenixGlobal) + NostrCredentialManager.loadAndDecrypt(phoenixGlobal) } _uiState.value = when (result) { - is DecryptNostrKeysResult.Success -> { - val privateKey = result.keys[identity.nostrPublicKey] - if (privateKey == null) { - logger.e { "key file holds no key for identity=${identity.id}" } + is DecryptNostrCredentialsResult.Success -> { + // A public entry is not a key either: this screen is not offered to a + // read-only identity, and if it were reached there is nothing to show. + val credential = result.credentials[identity.nostrPublicKey] as? NostrCredential.Secret + if (credential == null) { + logger.e { "credentials file holds no secret for identity=${identity.id}" } NostrSecretUIState.Error.NoKeyForThisIdentity } else { - NostrSecretUIState.Revealed(privateKey.value.toHex().hexToNsecHrp()) + NostrSecretUIState.Revealed(credential.privateKey.value.toHex().hexToNsecHrp()) } } - is DecryptNostrKeysResult.Failure.FileNotFound -> { - logger.e { "no key file, so no key for identity=${identity.id}" } + is DecryptNostrCredentialsResult.Failure.FileNotFound -> { + logger.e { "no credentials file, so no key for identity=${identity.id}" } NostrSecretUIState.Error.NoKeyForThisIdentity } - is DecryptNostrKeysResult.Failure -> { - logger.e { "unable to read the nostr keys: $result" } + is DecryptNostrCredentialsResult.Failure -> { + logger.e { "unable to read the nostr credentials: $result" } NostrSecretUIState.Error.KeysUnreadable } } @@ -174,15 +177,15 @@ class NostrSecretViewModel( logger.e("could not forget the key", throwable) _forgetting.value = NostrSecretUIState.Forgetting.Failed }) { - when (forgetNostrKey(identity)) { - is IdentityWriter.ForgetNostrKeyResult.Forgotten -> { + when (forgetNostrCredential(identity)) { + is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> { hideIdentityMetadata(identity) nostrRepository.forgetLocalAccount(identity.nostrPublicKey) _forgetting.value = NostrSecretUIState.Forgetting.Idle withContext(Dispatchers.Main) { onForgotten() } } - is IdentityWriter.ForgetNostrKeyResult.NotABareKey, - is IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys -> { + is IdentityWriter.ForgetNostrCredentialResult.NotACredential, + is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> { _forgetting.value = NostrSecretUIState.Forgetting.Failed } } @@ -196,7 +199,7 @@ class NostrSecretViewModel( phoenixGlobal: PhoenixGlobal, activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, - forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, hideIdentityMetadata: suspend (Identity) -> Unit, ): ViewModelProvider.Factory = viewModelFactory { initializer { @@ -204,7 +207,7 @@ class NostrSecretViewModel( phoenixGlobal = phoenixGlobal, activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, - forgetNostrKey = forgetNostrKey, + forgetNostrCredential = forgetNostrCredential, hideIdentityMetadata = hideIdentityMetadata, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt index df24172a..36993576 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt @@ -42,7 +42,7 @@ import press.mantra.compose.ui.view.state.form.SignInToProfileFormState */ class SignInToProfileViewModel( private val nostrRepository: NostrRepository, - private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, private val writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -56,7 +56,7 @@ class SignInToProfileViewModel( fun factory( nostrRepository: NostrRepository, - writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -111,9 +111,9 @@ class SignInToProfileViewModel( val written: Outcome = when (credential) { is SignInCredential.NostrSecret -> try { when (val result = writeNostrKey(credential.privateKey)) { - is IdentityWriter.WriteNostrKeyResult.Written -> Outcome.SignedIn(result.id) - is IdentityWriter.WriteNostrKeyResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) - is IdentityWriter.WriteNostrKeyResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) + is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) } } catch (e: CancellationException) { throw e diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 5f6e36ee..ecb26905 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -14,14 +14,15 @@ import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixGlobal import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey -import fr.acinq.phoenix.data.DecryptNostrKeysResult +import fr.acinq.phoenix.data.DecryptNostrCredentialsResult import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.security.NostrCredential import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata @@ -214,32 +215,32 @@ class SovereignWalletViewModel( is DecryptSeedResult.Success -> result.userWalletsMap } - val nostrKeys: Map = when (val result = NostrKeyManager.loadAndDecrypt(phoenixGlobal)) { - is DecryptNostrKeysResult.Failure.SerializationError -> { - log.e { "cannot deserialize nostr keys file" } + val credentials: Map = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) { + is DecryptNostrCredentialsResult.Failure.SerializationError -> { + log.e { "cannot deserialize nostr credentials file" } _listWalletState.value = ListWalletState.Error.Serialization return@launch } - is DecryptNostrKeysResult.Failure.DecryptionError -> { - log.e("cannot decrypt nostr keys file: ", throwable = result.cause) + is DecryptNostrCredentialsResult.Failure.DecryptionError -> { + log.e("cannot decrypt nostr credentials file: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) return@launch } - is DecryptNostrKeysResult.Failure.KeyStoreFailure -> { + is DecryptNostrCredentialsResult.Failure.KeyStoreFailure -> { log.e("key store failure: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) return@launch } - is DecryptNostrKeysResult.Failure.FileUnreadable -> { - log.e("aborting, unreadable nostr keys file") + is DecryptNostrCredentialsResult.Failure.FileUnreadable -> { + log.e("aborting, unreadable nostr credentials file") _listWalletState.value = ListWalletState.Error.Generic(null) return@launch } - is DecryptNostrKeysResult.Failure.FileNotFound -> emptyMap() - is DecryptNostrKeysResult.Success -> result.keys + is DecryptNostrCredentialsResult.Failure.FileNotFound -> emptyMap() + is DecryptNostrCredentialsResult.Success -> result.credentials } - val identities = StoredIdentity.merge(wallets, nostrKeys) + val identities = StoredIdentity.merge(wallets, credentials) val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() identities.keys.forEach { walletId -> @@ -357,7 +358,7 @@ class SovereignWalletViewModel( * Writes a bare nostr key as a new identity, with the same initialisation options * [writeSeed] applies. Suspends; the caller is the sign-in view model's coroutine. */ - suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrKeyResult = + suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrCredentialResult = IdentityWriter.writeNostrKey( log = log, phoenixGlobal = phoenixGlobal, @@ -367,9 +368,20 @@ class SovereignWalletViewModel( customElectrumServer = customElectrumServer.value, ) - /** The inverse of [writeNostrKey]. Only for a bare key; see [IdentityWriter.forgetNostrKey]. */ - suspend fun forgetNostrKey(identity: Identity): IdentityWriter.ForgetNostrKeyResult = - IdentityWriter.forgetNostrKey( + /** Writes a public key as a read-only identity, with the same options. */ + suspend fun writeNostrPublicKey(nostrPublicKey: HexKey): IdentityWriter.WriteNostrCredentialResult = + IdentityWriter.writeNostrPublicKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = getGlobalPrefs(), + nostrPublicKey = nostrPublicKey, + isTorEnabled = isTorEnabled.value, + customElectrumServer = customElectrumServer.value, + ) + + /** The inverse of both writers above. Only for a credential; see [IdentityWriter.forgetNostrCredential]. */ + suspend fun forgetNostrCredential(identity: Identity): IdentityWriter.ForgetNostrCredentialResult = + IdentityWriter.forgetNostrCredential( log = log, phoenixGlobal = phoenixGlobal, id = identity.id, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt index db77f8b4..28e2cb61 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt @@ -5,9 +5,11 @@ import co.touchlab.kermit.Logger import fr.acinq.bitcoin.PrivateKey import fr.acinq.lightning.Lightning import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential import fr.acinq.phoenix.utils.PlatformContext import fr.acinq.phoenix.utils.preferences.GlobalPrefs import kotlinx.coroutines.runBlocking @@ -24,12 +26,15 @@ import kotlin.test.assertIs import kotlin.test.assertTrue /** - * The two writers for a bare key, against a real key store and a real directory. + * The writers for a bare key and a bare public key, against a real key store and a + * real directory. * - * What is pinned is the duplicate rule and the inverse. A second import of the same key - * is refused by public key, the same npub imported twice being the one thing the id - * check cannot see; and forgetting takes the key out of the file and the identity's - * preference files off the disk, leaving every other key where it was. + * What is pinned is the duplicate rule, its one exception, and the inverse. A second + * import of the same key is refused by public key, the same npub imported twice being + * the one thing the id check cannot see; a public key already here is *not* a duplicate + * of the secret that signs as it, and the entry becomes a secret one under the id it + * had; and forgetting takes the entry, whichever kind, out of the file and the identity's + * preference files off the disk, leaving every other entry where it was. */ class IdentityWriterJvmTest { @@ -76,36 +81,96 @@ class IdentityWriterJvmTest { customElectrumServer = null, ) - private fun userPrefsFile(stored: StoredIdentity.NostrSecret) = + private suspend fun writePublic(key: PrivateKey) = IdentityWriter.writeNostrPublicKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = globalPrefs, + nostrPublicKey = key.nostrPublicKeyHex(), + isTorEnabled = false, + customElectrumServer = null, + ) + + private fun credentials() = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + + private fun userPrefsFile(stored: StoredIdentity) = computePreferencePath(phoenixGlobal.ctx, "userprefs_${stored.id.nodeIdHash}.preferences_pb") @Test fun `a key is written once and refused the second time, by public key`() = runBlocking { val stored = StoredIdentity.nostrSecret(first) - val written = assertIs(write(first)) + val written = assertIs(write(first)) assertEquals(stored.id, written.id) - assertEquals(mapOf(stored.nostrPublicKey to first), NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)) + assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Secret(first)), credentials()) assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)), "the preferences file the recovery screens read") - assertIs(write(first)) + assertIs(write(first)) } @Test - fun `forgetting one key leaves the other, and takes the preferences with it`() = runBlocking { + fun `a public key is written once and refused the second time`() = runBlocking { + val stored = StoredIdentity.nostrPublic(first.nostrPublicKeyHex()) + + val written = assertIs(writePublic(first)) + assertEquals(stored.id, written.id) + assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Public), credentials()) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored))) + + assertIs(writePublic(first)) + } + + /** + * The one exception to the duplicate rule, and the reason the two kinds share a + * file: the device does not hold this secret, so refusing it would be false. One + * write, same id, and the preferences the read-only identity had are the ones the + * signing identity keeps. + */ + @Test + fun `the nsec of a key held read-only is accepted, under the same id, and the entry becomes a secret`() = runBlocking { + val readOnly = assertIs(writePublic(first)) + writePublic(second) + + val upgraded = assertIs(write(first)) + + assertEquals(readOnly.id, upgraded.id) + assertEquals( + mapOf( + first.nostrPublicKeyHex() to NostrCredential.Secret(first), + second.nostrPublicKeyHex() to NostrCredential.Public, + ), + credentials(), + ) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrSecret(first)))) + } + + /** The other direction is a duplicate: the device already holds more than an npub for the key. */ + @Test + fun `the npub of a key held as a secret is refused`() = runBlocking { write(first) - write(second) + + assertIs(writePublic(first)) + assertEquals(mapOf(first.nostrPublicKeyHex() to NostrCredential.Secret(first)), credentials()) + } + + @Test + fun `forgetting one entry leaves the others, whichever kind, and takes the preferences with it`() = runBlocking { + write(first) + writePublic(second) val stored = StoredIdentity.nostrSecret(first) - val result = IdentityWriter.forgetNostrKey(log, phoenixGlobal, stored.id, stored.nostrPublicKey) + val result = IdentityWriter.forgetNostrCredential(log, phoenixGlobal, stored.id, stored.nostrPublicKey) - assertIs(result) - assertEquals( - mapOf(StoredIdentity.nostrSecret(second).nostrPublicKey to second), - NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal), - ) + assertIs(result) + assertEquals(mapOf(second.nostrPublicKeyHex() to NostrCredential.Public), credentials()) assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(stored))) - assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrSecret(second)))) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrPublic(second.nostrPublicKeyHex())))) + + val readOnly = StoredIdentity.nostrPublic(second.nostrPublicKeyHex()) + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, readOnly.id, readOnly.nostrPublicKey) + ) + assertEquals(emptyMap(), credentials()) + assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(readOnly))) } /** A wallet's nostr key is in the seed, not here; removing a seed is a wallet question. */ @@ -114,9 +179,9 @@ class IdentityWriterJvmTest { write(first) val other = StoredIdentity.nostrSecret(second) - assertIs( - IdentityWriter.forgetNostrKey(log, phoenixGlobal, other.id, other.nostrPublicKey) + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, other.id, other.nostrPublicKey) ) - assertEquals(1, NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)?.size) + assertEquals(1, credentials()?.size) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt index 97f23166..085dac31 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt @@ -9,7 +9,7 @@ import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.jvm.BusinessManager import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager import fr.acinq.phoenix.managers.SeedManager import fr.acinq.phoenix.managers.computePreferencePath import fr.acinq.phoenix.security.JvmKeyStore @@ -113,14 +113,14 @@ class NsecRestoreRoundTripJvmTest { @Test fun `an nsec written, listed, activated and routed, with no node started`() = runBlocking { // 1. The sign-in screen's commit, minus the screen. - val written = assertIs( + val written = assertIs( IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, privateKey, isTorEnabled = false, customElectrumServer = null) ) // 2. What startup lists: both stores, merged. val identities = StoredIdentity.merge( wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"), - nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("key store unreadable"), + credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("key store unreadable"), ) val stored = assertIs(identities[written.id], "the imported key is listed under the id the writer returned") diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt index b1a44869..5f962472 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt @@ -4,6 +4,7 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.security.NostrCredential import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -29,6 +30,7 @@ class StoredIdentityJvmTest { words = words, ) private val bareKey = PrivateKey(ByteVector32("02".repeat(32))) + private val readOnlyKey = PrivateKey(ByteVector32("03".repeat(32))).publicKey().xOnly().value.toHex() @Test fun `a wallet's nostr key is the NIP-06 derivation of its words`() { @@ -40,10 +42,13 @@ class StoredIdentityJvmTest { fun `both stores land in one map, each under its own id`() { val merged = StoredIdentity.merge( wallets = mapOf(wallet.walletId to wallet), - nostrKeys = mapOf(bareKey.publicKey().xOnly().value.toHex() to bareKey), + credentials = mapOf( + bareKey.publicKey().xOnly().value.toHex() to NostrCredential.Secret(bareKey), + readOnlyKey to NostrCredential.Public, + ), ) - assertEquals(2, merged.size) + assertEquals(3, merged.size) val mnemonic = assertIs(merged[wallet.walletId]) assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) assertEquals(IdentityKind.Mnemonic, mnemonic.kind) @@ -52,6 +57,19 @@ class StoredIdentityJvmTest { assertEquals(IdentityKind.NostrSecret, secret.kind) assertEquals(40, secret.id.nodeIdHash.length, "same shape as a wallet's id") assertNotEquals(wallet.walletId, secret.id) + + val public = assertIs(merged[readOnlyKey.toXonlyPublicKey().toWalletId()]) + assertEquals(IdentityKind.NostrPublic, public.kind) + assertEquals(readOnlyKey, public.nostrPublicKey) + } + + /** The id a read-only identity gets is the one its secret would get, so an upgrade keeps it. */ + @Test + fun `a public key and its secret get the same id`() { + assertEquals( + StoredIdentity.nostrSecret(bareKey).id, + StoredIdentity.nostrPublic(bareKey.publicKey().xOnly().value.toHex()).id, + ) } /** @@ -63,18 +81,36 @@ class StoredIdentityJvmTest { fun `a wallet and its own nostr key as a bare secret do not collide by id`() { val merged = StoredIdentity.merge( wallets = mapOf(wallet.walletId to wallet), - nostrKeys = mapOf(nip06PublicKey to nip06PrivateKey), + credentials = mapOf(nip06PublicKey to NostrCredential.Secret(nip06PrivateKey)), ) assertEquals(2, merged.size) assertEquals(1, merged.values.map { it.nostrPublicKey }.toSet().size, "one npub, twice") } + /** + * The one precedence the merge decides. A recovery phrase pasted over a public + * credential is written across two files -- the credential removed first, then the + * seed -- and a crash between the two would leave neither, never both; this is the + * rule for the "both" that is not supposed to happen, so that it lists the wallet + * rather than one npub twice. + */ + @Test + fun `a public key a seed already derives is listed as the wallet only`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + credentials = mapOf(nip06PublicKey to NostrCredential.Public), + ) + + assertEquals(1, merged.size) + assertIs(merged[wallet.walletId]) + } + @Test fun `a key filed under a public key it does not derive is dropped`() { val merged = StoredIdentity.merge( wallets = emptyMap(), - nostrKeys = mapOf(nip06PublicKey to bareKey), + credentials = mapOf(nip06PublicKey to NostrCredential.Secret(bareKey)), ) assertEquals(emptyMap(), merged) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt index 1e8c8de6..209d1e12 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt @@ -79,26 +79,26 @@ class NostrSecretViewModelJvmTest { private fun viewModel( recorder: Recorder, - forgetOutcome: IdentityWriter.ForgetNostrKeyResult, + forgetOutcome: IdentityWriter.ForgetNostrCredentialResult, ) = NostrSecretViewModel( phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = temporaryFolder.newFolder())), activeIdentityStateFlow = MutableStateFlow(identity()), nostrRepository = recorder, - forgetNostrKey = { recorder.effects += "forgetNostrKey"; forgetOutcome }, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; forgetOutcome }, hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, ) @Test fun `the key comes out first, then the metadata, the account, and the caller is told`() { val recorder = Recorder() - val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.Forgotten) + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) val told = CompletableDeferred() viewModel.forgetKey { told.complete(Unit) } runBlocking { withTimeout(10_000) { told.await() } } assertEquals( - listOf("forgetNostrKey", "hideIdentityMetadata", "forgetLocalAccount"), + listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount"), recorder.effects, ) assertEquals(NostrSecretUIState.Forgetting.Idle, viewModel.forgetting.value) @@ -107,7 +107,7 @@ class NostrSecretViewModelJvmTest { @Test fun `a key that could not be removed leaves everything else alone`() { val recorder = Recorder() - val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys) + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) viewModel.forgetKey { error("must not be told") } val state = runBlocking { @@ -115,6 +115,6 @@ class NostrSecretViewModelJvmTest { } assertEquals(NostrSecretUIState.Forgetting.Failed, state) - assertEquals(listOf("forgetNostrKey"), recorder.effects) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt index cc247437..06baab6d 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt @@ -42,7 +42,7 @@ class SignInToProfileViewModelJvmTest { private fun viewModel( recorder: Recorder, - nostrKeyOutcome: () -> IdentityWriter.WriteNostrKeyResult = { IdentityWriter.WriteNostrKeyResult.Written(id) }, + nostrKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) }, ) = SignInToProfileViewModel( nostrRepository = recorder, @@ -74,7 +74,7 @@ class SignInToProfileViewModelJvmTest { fun `a key already on the device is refused, and no account is planted`() = runBlocking { val recorder = Recorder() - val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.AlreadyExists }).commit(nostrSecret) + val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrSecret) assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) assertEquals(listOf("writeNostrKey"), recorder.effects) @@ -97,7 +97,7 @@ class SignInToProfileViewModelJvmTest { assertEquals( SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), - viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }).commit(nostrSecret), + viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }).commit(nostrSecret), ) assertEquals( SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), diff --git a/lightning-kmp-app b/lightning-kmp-app index 59c11ed9..84cc44c8 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 59c11ed926ac05c820b98afb4cc961a5a3506310 +Subproject commit 84cc44c855e3d173e26016b807c449153a3af597 From 6532bfc45fa1c384ff9e5751978af000812d94fd Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:28:57 +0200 Subject: [PATCH 31/71] feat(identity): list, start and read as an identity that holds no key Phase 3 of docs/npub-sign-in.md. listIdentities runs the migration from nostr-keys.dat before its read -- there rather than inside the reader, so a listing that writes is a named step and not a surprise, and from init, before anything else touches either file. An old file that cannot be read is left where it is and reported the way an unreadable credentials file is, so the user sees an error rather than silently losing every imported identity. The selector says "read only" under the npub of a public-key identity, before the tap: two identities can share an avatar and a name, and only one of them will let the user send a message. Identity.toKeyPair is now the one place a quartz KeyPair is built from an identity. With a secret it is the pair as before, the public key a cross-check; without one it is quartz's read-only constructor. What it must never be is KeyPair(privKey = null) with nothing else -- that is quartz's "make me a new key" -- and decryptGiftWrapSeal builds exactly that from a forwarded pair, which is why the helper has the trap on it. The synchronization view model runs the two pumps that read for every identity and the two that write only for one that can sign. Stated as a rule because Phase 5 leans on it: a read-only identity never sends anything to a relay, not a signature and not a copy. The broadcast pump would find nothing, and the live subscriptions are the gift-wrap inbox and the group-membership follow, both fetching what this identity cannot open. One guard in NostrDao.indexNostrEvent, after the isAddressedTo check: a wrap addressed to a key the pair does not hold is kept, event and wrap, the way someone else's mail already is. storeNostrEvent is @Transaction and indexes inside it, so before this the unseal ran with a key nobody has, threw, and the throw took the event with it while logging as a decryption failure. Verified both ways: the new DAO test fails on exactly that case with the guard removed and passes with it, and the same wrap opens once the pair holds its key. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@7db863e3925641bc731816f72bfed90f020566c6 --- .../composeResources/values/strings.xml | 1 + .../mantra/compose/database/dao/NostrDao.kt | 11 ++ .../press/mantra/compose/identity/Identity.kt | 18 +++ .../widgets/wallet/WalletsSelector.kt | 13 +++ .../ui/view/model/SovereignWalletViewModel.kt | 22 ++++ .../ui/view/model/SynchronizationViewModel.kt | 29 +++-- .../dao/ReadOnlyGiftWrapDaoJvmTest.kt | 106 ++++++++++++++++++ 7 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 9244b7d6..6c2c5394 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -265,6 +265,7 @@ This will be the display name for this chat room. This will be the display name for your profile and also important for search. This will give you read only access to the profile. + Read only This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 4750536b..f650955b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -445,6 +445,17 @@ abstract class NostrDao( return@let } + if (activeKeyPair.privKey == null) { + // Addressed to us, and we cannot open it: a read-only identity. Keep the + // event and the wrap, as the branch above does for wraps addressed to + // someone else; the key that opens this one may be signed in later. Without + // this, the unseal below would run with a key nobody has and throw, and + // the throw would take the event with it. + logger.d("GiftWrap ${nostrEvent.id} is addressed to us, but this identity holds no key") + + return@let + } + giftWrapMessage.decryptGiftWrapSeal( activeKeyPair ).let { giftWrapSeal -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt index 9ed2737e..7ccd555a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -1,6 +1,8 @@ package press.mantra.compose.identity import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.XonlyPublicKey @@ -135,6 +137,22 @@ data class Identity( } } +/** + * The one place a quartz [KeyPair] is built from an identity. + * + * With a secret, quartz recomputes the public key from it and the second argument is a + * cross-check. Without one it is quartz's read-only constructor, which its own source + * comments as "this is a read-only account". What it must never be is + * `KeyPair(privKey = null)` with nothing else: that is quartz's *make me a new key*, and + * a read-only pair forwarded into it would try to open the user's gift wraps with a key + * nobody has, fail, and look in the logs like a decryption failure. This helper exists so + * that the trap has one place to be avoided. + */ +fun Identity.toKeyPair(): KeyPair = KeyPair( + privKey = nostrPrivateKey?.value?.toByteArray(), + pubKey = nostrPublicKey.hexToByteArray(), +) + /** * The [WalletId] of an identity that has no node id to hash. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index ad609bd3..b273d2d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -49,6 +49,9 @@ import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import press.mantra.compose.extensions.hexToNpubHrp +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.read_only +import org.jetbrains.compose.resources.stringResource import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing @@ -159,6 +162,16 @@ private fun AvailableWalletView( Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(MaterialTheme.spacing.space25)) Text(text = identity.nostrPublicKey.hexToNpubHrp(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + if (identity is StoredIdentity.NostrPublic) { + // Said before the tap, not after: two identities can share an avatar + // and a name, and only one of them will let the user send a message. + Spacer(Modifier.height(MaterialTheme.spacing.space25)) + Text( + text = stringResource(Res.string.read_only), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } if (isCurrent && canEdit) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index ecb26905..d000c5c2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -15,6 +15,7 @@ import fr.acinq.phoenix.PhoenixGlobal import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.DecryptNostrCredentialsResult +import fr.acinq.phoenix.data.DecryptNostrKeysResult import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState @@ -215,6 +216,27 @@ class SovereignWalletViewModel( is DecryptSeedResult.Success -> result.userWalletsMap } + // Before the read, and here rather than in the reader: a listing that writes + // would be a surprise, and this runs from init, before anything else touches + // either file. An old file that cannot be read is left alone and reported the + // way an unreadable credentials file is, so the user sees the error rather + // than silently losing every imported identity. + when (val migration = NostrCredentialManager.migrateFromNostrKeys(phoenixGlobal)) { + is NostrCredentialManager.MigrationResult.Migrated -> log.i { "migrated ${migration.count} nostr key(s) into the credentials file" } + is NostrCredentialManager.MigrationResult.NotNeeded -> Unit + is NostrCredentialManager.MigrationResult.Failed -> { + log.e { "nostr-keys.dat could not be migrated: ${migration.failure}" } + _listWalletState.value = when (val failure = migration.failure) { + is DecryptNostrKeysResult.Failure.SerializationError -> ListWalletState.Error.Serialization + is DecryptNostrKeysResult.Failure.DecryptionError -> ListWalletState.Error.DecryptionError.GeneralException(failure.cause) + is DecryptNostrKeysResult.Failure.KeyStoreFailure -> ListWalletState.Error.DecryptionError.KeystoreFailure(failure.cause) + is DecryptNostrKeysResult.Failure.FileUnreadable, + is DecryptNostrKeysResult.Failure.FileNotFound -> ListWalletState.Error.Generic(null) + } + return@launch + } + } + val credentials: Map = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) { is DecryptNostrCredentialsResult.Failure.SerializationError -> { log.e { "cannot deserialize nostr credentials file" } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index cd4428a0..63dcd40a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.toKeyPair import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -191,10 +192,10 @@ class SynchronizationViewModel( scope.launch { activeIdentityStateFlow.collectLatest { identity -> logger.d("Synchronization identity: $identity") - identity?.nostrPrivateKey?.let { nostrPrivateKey -> - val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() - ) + identity?.let { + // Through the helper, never by hand: a read-only identity has no private + // key, and quartz's KeyPair given neither key generates a fresh one. + val keyPair = identity.toKeyPair() // Anything a previous run left mid-flight is ours to retry: it was flipped to // "processing" before the publish and nothing observes that status, so @@ -215,13 +216,23 @@ class SynchronizationViewModel( // throw in one must not take the other two down with it for the rest of the // session. Each pump also guards its own per-request body (see guardPump). supervisorScope { - launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) } launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) } - // Runs until cancelled rather than draining a queue, but it belongs - // here for the same reason the pumps do: it needs the active wallet's - // key pair, and a wallet switch must tear it down. - launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + // A read-only identity runs the two pumps that read and neither of + // the two that write. The rule Phase 5 of docs/npub-sign-in.md + // leans on: such an identity never sends anything to a relay, not a + // signature and not a copy. The broadcast pump would find nothing -- + // nothing is ever signed -- and the live subscriptions are the + // gift-wrap inbox and the group-membership follow, both fetching + // things this identity cannot open. Not asking is not an + // optimisation; it is the identity not asking for what it cannot use. + if (identity.canSign) { + launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } + // Runs until cancelled rather than draining a queue, but it + // belongs here for the same reason the pumps do: it needs the + // active wallet's key pair, and a wallet switch must tear it down. + launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) } + } } } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt new file mode 100644 index 00000000..7a4478fc --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.NostrEvent +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Instant + +/** + * A gift wrap addressed to a read-only identity is kept and not opened. + * + * `storeNostrEvent` is `@Transaction` and indexes inside it, and a wrap addressed to the + * active key that cannot be unsealed throws -- so before the guard in `indexNostrEvent` + * the throw took the event with it, and looked in the logs like a decryption failure. + * Worse, `decryptGiftWrapSeal` builds `KeyPair(privKey = keyPair.privKey)`, and in + * quartz a `KeyPair` given neither key generates a fresh one: the unseal would have run + * with a key nobody has. A read-only identity holds no key by construction, and the + * wrap is what the branch for someone else's mail already does with it: stored, so the + * key that opens it can be signed in later. + */ +class ReadOnlyGiftWrapDaoJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val relay = "wss://relay.example" + + /** The user, as the sync pump sees them when the device holds only their public key. */ + private val us = KeyPair() + private val readOnlyUs = KeyPair(pubKey = us.pubKey) + private val peer = KeyPair() + + private fun wrapTo(recipient: KeyPair): NostrEvent { + val signer = NostrSignerSync(peer) + val seal = signer.signNormal( + createdAt = 1_700_000_000, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt( + plaintext = """{"kind":14,"content":"dumela"}""", + toPublicKey = recipient.pubKey.toHexKey(), + ), + ) + val giftWrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = recipient.pubKey.toHexKey(), + createdAt = 1_700_000_100, + ) + return NostrEvent( + id = giftWrap.id, + pubKey = giftWrap.pubKey, + kind = giftWrap.kind, + tags = giftWrap.tags, + content = giftWrap.content, + sig = giftWrap.sig, + createdAt = Instant.fromEpochSeconds(giftWrap.createdAt), + ) + } + + @Test + fun `a wrap addressed to a read-only identity is stored, and its seal is not`() = runBlocking { + val wrap = wrapTo(us) + + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = readOnlyUs, + ) + + assertNotNull(db.nostrEventDao().getNostrEventById(wrap.id), "the event itself must survive the failed unseal") + assertEquals(listOf(wrap.id), db.giftWrapMessageDao().getAllGiftWrapMessages().map { it.id }) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals(), "nothing was opened") + } + + /** The same wrap, once the key is here: the seal comes out. The guard is only about the key's absence. */ + @Test + fun `the same wrap opens once the identity holds its key`() = runBlocking { + val wrap = wrapTo(us) + + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = us, + ) + + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) + } +} From 4c04d3b12bfa83325e054b1a8fba8b2525070a25 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:32:54 +0200 Subject: [PATCH 32/71] feat(sign-in): an npub, recognised, confirmed as read-only, then written Phase 4 of docs/npub-sign-in.md. The field accepts a third thing. CredentialParser recognises npub1... and nostr:npub1... as SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix that name a point on the curve -- the counterpart of the isValid() a secret is checked with, since not every x coordinate has a point above it. PublicKeyOnly goes, and its string with it; an npub that does not decode, or names no point, is InvalidKey. Hex stays a secret: a private key and an x-only public key are the same size, the confirm step shows the derived npub so a public key pasted as hex is visible for what it is, and guessing would never fire when it mattered, since an x coordinate is almost always also a valid scalar. The test pins the vector's own public key, pasted as hex, landing on a different npub. The confirm step's third arm says what the user is about to get and not get, once, before the choice: this profile and the people it follows; messages closed and nothing sent; paste the nsec later to open it. The landing caption, the field's label and its placeholder name the third input, the last with "to look around" for a user who does not know the word read-only. The view model takes the third writer the way it takes the other two and commits through one shared write-and-map. signInToProfile is idempotent by pubkey. Until now nothing called it twice for one key -- the writers refused a second sign-in before it got there. Pasting the nsec of a key held read-only is the first path that signs in a pubkey whose account already exists, and a second placeholder would be two kind-0 rows for one pubkey, two accounts disagreeing about which is this one. A repository test signs in twice and counts one. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@bfad1f39a2030ccf52cee7bf390bb9d68d33e016 --- .../composeResources/values/strings.xml | 9 ++-- .../repository/DatabaseNostrRepository.kt | 11 ++++ .../compose/identity/CredentialParser.kt | 52 +++++++++++++----- .../compose/ui/composable/LandingScreen.kt | 4 +- .../compose/ui/composable/SignInScreen.kt | 25 ++++++--- .../ui/composable/navigation/MantraNavHost.kt | 1 + .../ui/view/model/SignInToProfileViewModel.kt | 52 ++++++++++-------- .../repository/SignInToProfileTwiceJvmTest.kt | 54 +++++++++++++++++++ .../identity/CredentialParserJvmTest.kt | 32 +++++++++-- .../model/SignInToProfileViewModelJvmTest.kt | 23 ++++++++ 10 files changed, 214 insertions(+), 49 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6c2c5394..1f3907b6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -264,7 +264,6 @@ This will be shown when people open your profile. This will be the display name for this chat room. This will be the display name for your profile and also important for search. - This will give you read only access to the profile. Read only This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. @@ -649,13 +648,13 @@ Copied the raw JSON A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec. - An npub is a public key. Mantra needs the secret key, the nsec, to sign as you. Enter a recovery phrase or an nsec to sign in. Paste Recognised as a nostr secret key. No wallet comes with it. Recognised as a recovery phrase. It also restores the wallet. - Recovery phrase or nsec - Sign in with a recovery phrase or an nsec + Recognised as a public key. You will see this profile and the people it follows; messages stay closed and nothing can be sent. Paste the nsec later to open it. + Recovery phrase, nsec or npub + Sign in with a recovery phrase, an nsec, or an npub to look around Signed in Signing you in… That is not a recovery phrase or a nostr key. @@ -664,7 +663,7 @@ The key could not be saved on this device. Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm. This key is already on this device. - Twelve words, or nsec1… + Twelve words, nsec1… or npub1… Use a different key You are signing in as diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index d9218c2b..44c45d97 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -258,6 +258,17 @@ class DatabaseNostrRepository( override suspend fun signInToProfile( publicKey: HexKey ) { + // Idempotent by pubkey. Until the read-only kind, nothing called this twice for + // one key -- the writers refused a second sign-in before it got here. Pasting the + // nsec of a key held read-only is the first path that signs in a pubkey whose + // account already exists, and a second placeholder would be two kind-0 rows for + // one pubkey: two accounts disagreeing about which is this one, the failure + // `setUpProfileForExistingKey`'s own comment describes. + if (database.unsignedNostrEventDao().getLocalAccounts().any { it.unsignedNostrEvent?.pubKey == publicKey }) { + logger.d("signInToProfile: $publicKey already has an account here, nothing to plant") + return + } + val profileEventTemplate = MetadataEvent.createNew() saveUnsignedNostrEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt index ffa2f11c..b8560fde 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt @@ -2,16 +2,18 @@ package press.mantra.compose.identity import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.MnemonicCode import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.utils.MnemonicLanguage /** * What a user pasted into the sign-in field, once it has been recognised. * - * Both carry the nostr public key, because the confirm step shows it and the duplicate - * check compares it. Neither prints its secret. + * All three carry the nostr public key, because the confirm step shows it and the + * duplicate check compares it. None prints a secret. */ sealed interface SignInCredential { val nostrPublicKey: HexKey @@ -31,6 +33,11 @@ sealed interface SignInCredential { ) : SignInCredential { override fun toString(): String = "NostrSecret(npub=$nostrPublicKey, key=)" } + + /** A bare nostr public key. Nothing can be signed with it: a profile to look at. */ + data class NostrPublicKey( + override val nostrPublicKey: HexKey, + ) : SignInCredential } /** Why an input was not accepted. The screen turns each into a sentence. */ @@ -44,12 +51,9 @@ enum class CredentialProblem { /** Words, but the checksum or the wordlist says no: a wrong word, or one out of order. */ InvalidPhrase, - /** An nsec or hex that does not decode to a valid secp256k1 secret. */ + /** An nsec or hex that does not decode to a valid secp256k1 secret, or an npub that does not decode to a point on the curve. */ InvalidKey, - /** An npub. A public key cannot sign, and every write path here assumes a key that can. */ - PublicKeyOnly, - /** An ncryptsec (NIP-49). Decrypting one is a follow-on; say so rather than fail opaquely. */ EncryptedKey, @@ -63,11 +67,18 @@ enum class CredentialProblem { /** * Recognises a pasted credential. Pure, so it can be pinned row by row. * - * A user does not choose an input type; they paste what they have. The two accepted - * shapes are unambiguous -- words have spaces, keys do not -- and the two refused - * shapes are named for what they are rather than lumped in with garbage, because a - * user who pasted an npub or an ncryptsec did something reasonable and should be told - * what to do instead. + * A user does not choose an input type; they paste what they have. The three accepted + * shapes are unambiguous -- words have spaces, keys do not, and a bech32 prefix says + * which key -- and the one refused shape is named for what it is rather than lumped in + * with garbage, because a user who pasted an ncryptsec did something reasonable and + * should be told what to do instead. + * + * **Hex stays a secret.** A private key and an x-only public key are both thirty-two + * bytes, and sixty-four hex characters cannot say which. The confirm step shows the + * derived npub, so a public key pasted as hex is visible for what it is; guessing -- + * "not a valid secret, so try it as a public key" -- would never fire when it mattered, + * since an x coordinate is, with overwhelming probability, also a valid scalar. An npub + * has to arrive as an npub. */ object CredentialParser { @@ -91,7 +102,7 @@ object CredentialParser { // prefix is how a key arrives from a link. val token = text.removePrefix("nostr:").lowercase() return when { - token.startsWith("npub1") -> Result.Refused(CredentialProblem.PublicKeyOnly) + token.startsWith("npub1") -> npub(token) token.startsWith("ncryptsec1") -> Result.Refused(CredentialProblem.EncryptedKey) token.startsWith("nsec1") -> nsec(token) hex64.matches(token) -> secret(runCatching { PrivateKey.fromHex(token) }.getOrNull()) @@ -119,4 +130,21 @@ object CredentialParser { if (privateKey == null || !privateKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex())) } + + /** + * Thirty-two bytes under the `npub` prefix that name a point on the curve -- the + * counterpart of the `isValid()` a secret is checked with, since not every x + * coordinate has a point above it. + */ + private fun npub(token: String): Result { + val bytes = runCatching { + val (hrp, data) = Bech32.decodeBytes(token) + require(hrp == "npub") { "not an npub" } + require(data.size == 32) { "not an x-only key" } + data + }.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey) + val xOnly = XonlyPublicKey(ByteVector32(bytes)) + if (!xOnly.publicKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) + return Result.Recognised(SignInCredential.NostrPublicKey(xOnly.value.toHex())) + } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt index acf73b4d..f061ccc5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt @@ -27,7 +27,7 @@ import mantra.composeapp.generated.resources.keep_the_feed_alive import mantra.composeapp.generated.resources.learn_more import mantra.composeapp.generated.resources.mantra import mantra.composeapp.generated.resources.sign_in -import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_or_an_nsec +import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_an_nsec_or import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent @@ -82,7 +82,7 @@ fun LandingScreen( } Text( - text = stringResource(Res.string.sign_in_with_a_recovery_phrase_or_an_nsec), + text = stringResource(Res.string.sign_in_with_a_recovery_phrase_an_nsec_or), modifier = Modifier.padding(MaterialTheme.spacing.space125), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index e66ecf99..050702ba 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -38,13 +39,13 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.a_password_protected_key_ncryptsec_is_not -import mantra.composeapp.generated.resources.an_npub_is_a_public_key_mantra_needs_the import mantra.composeapp.generated.resources.enter_a_recovery_phrase_or_an_nsec_to_sign_in import mantra.composeapp.generated.resources.next import mantra.composeapp.generated.resources.paste import mantra.composeapp.generated.resources.recognised_as_a_nostr_secret_key_no_wallet +import mantra.composeapp.generated.resources.recognised_as_a_public_key_you_will_see import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_also -import mantra.composeapp.generated.resources.recovery_phrase_or_nsec +import mantra.composeapp.generated.resources.recovery_phrase_nsec_or_npub import mantra.composeapp.generated.resources.sign_in import mantra.composeapp.generated.resources.signed_in import mantra.composeapp.generated.resources.signing_you_in @@ -54,9 +55,10 @@ import mantra.composeapp.generated.resources.that_recovery_phrase_is_not_valid_c import mantra.composeapp.generated.resources.the_key_could_not_be_saved_on_this_device import mantra.composeapp.generated.resources.the_words_or_the_key_never_leave_this_device import mantra.composeapp.generated.resources.this_key_is_already_on_this_device -import mantra.composeapp.generated.resources.twelve_words_or_nsec1 +import mantra.composeapp.generated.resources.twelve_words_nsec1_or_npub1 import mantra.composeapp.generated.resources.use_a_different_key import mantra.composeapp.generated.resources.you_are_signing_in_as +import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.WalletId import org.jetbrains.compose.resources.stringResource @@ -93,6 +95,7 @@ import press.mantra.compose.ui.view.state.SignInToProfileUIState fun SignInToProfileScreen( nostrRepository: NostrRepository, writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -105,6 +108,7 @@ fun SignInToProfileScreen( factory = SignInToProfileViewModel.factory( nostrRepository = nostrRepository, writeNostrKey = writeNostrKey, + writeNostrPublicKey = writeNostrPublicKey, writeRecoveryPhrase = writeRecoveryPhrase, ) ) @@ -191,8 +195,8 @@ private fun InputPrompt(viewModel: SignInToProfileViewModel) { keyboardType = KeyboardType.Ascii, ), textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), - label = { Text(text = stringResource(Res.string.recovery_phrase_or_nsec), maxLines = 1) }, - placeholder = { Text(text = stringResource(Res.string.twelve_words_or_nsec1), maxLines = 1) }, + label = { Text(text = stringResource(Res.string.recovery_phrase_nsec_or_npub), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.twelve_words_nsec1_or_npub1), maxLines = 1) }, trailingIcon = { IconButton( onClick = { @@ -260,6 +264,15 @@ private fun Confirm( style = MaterialTheme.typography.bodyMedium, ) } + is SignInCredential.NostrPublicKey -> { + // What the user is about to get and not get, said here and nowhere + // else: this is the one step before the choice is made. + Icon(Icons.Default.Visibility, contentDescription = Decorative) + Text( + text = stringResource(Res.string.recognised_as_a_public_key_you_will_see), + style = MaterialTheme.typography.bodyMedium, + ) + } } } @@ -286,7 +299,6 @@ private fun problemMessage(problem: CredentialProblem): String = stringResource( CredentialProblem.NotRecognised -> Res.string.that_is_not_a_recovery_phrase_or_a_nostr_key CredentialProblem.InvalidPhrase -> Res.string.that_recovery_phrase_is_not_valid_check CredentialProblem.InvalidKey -> Res.string.that_nostr_secret_key_is_not_valid - CredentialProblem.PublicKeyOnly -> Res.string.an_npub_is_a_public_key_mantra_needs_the CredentialProblem.EncryptedKey -> Res.string.a_password_protected_key_ncryptsec_is_not CredentialProblem.AlreadyOnThisDevice -> Res.string.this_key_is_already_on_this_device CredentialProblem.CouldNotWrite -> Res.string.the_key_could_not_be_saved_on_this_device @@ -303,6 +315,7 @@ private fun SignInToProfileScreenPreview() { SignInToProfileScreen( nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, writeNostrKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, + writeNostrPublicKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, onNavigateBack = {}, onSignedIn = {}, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 5d83c85a..45da90f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -704,6 +704,7 @@ fun MantraNavHost( SignInToProfileScreen( nostrRepository = databaseNostrRepository, writeNostrKey = { privateKey -> sovereignWalletViewModel.writeNostrKey(privateKey) }, + writeNostrPublicKey = { publicKey -> sovereignWalletViewModel.writeNostrPublicKey(publicKey) }, writeRecoveryPhrase = { words, onWritten, onError -> sovereignWalletViewModel.writeSeed( words, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt index 36993576..82e9a8be 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.WalletId import kotlinx.coroutines.CancellationException @@ -25,14 +26,15 @@ import press.mantra.compose.ui.view.state.SignInToProfileUIState import press.mantra.compose.ui.view.state.form.SignInToProfileFormState /** - * Signs in with a secret the user already has: a recovery phrase or an nsec. + * Signs in with something the user already has: a recovery phrase, an nsec, or -- to + * look and not sign -- an npub. * - * The two writers are injected as functions rather than reached for, so the commit can - * be driven in a test without a key store, and because the recovery-phrase writer is - * `SovereignWalletViewModel.writeSeed`, which lives in another view model and drives a - * state machine of its own. + * The three writers are injected as functions rather than reached for, so the commit + * can be driven in a test without a key store, and because the recovery-phrase writer + * is `SovereignWalletViewModel.writeSeed`, which lives in another view model and drives + * a state machine of its own. * - * What this does not do is decide where the user goes next. It writes the secret, plants + * What this does not do is decide where the user goes next. It writes the credential, plants * the placeholder account, and hands the new id to [onSignedIn]; the nav host re-lists * identities, selects that one and goes to startup, which is the same tail the create * flow uses. The **order** inside [commit] is load-bearing: the account has to be in the @@ -43,6 +45,7 @@ import press.mantra.compose.ui.view.state.form.SignInToProfileFormState class SignInToProfileViewModel( private val nostrRepository: NostrRepository, private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + private val writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, private val writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -57,6 +60,7 @@ class SignInToProfileViewModel( fun factory( nostrRepository: NostrRepository, writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -67,6 +71,7 @@ class SignInToProfileViewModel( SignInToProfileViewModel( nostrRepository = nostrRepository, writeNostrKey = writeNostrKey, + writeNostrPublicKey = writeNostrPublicKey, writeRecoveryPhrase = writeRecoveryPhrase, ) } @@ -102,25 +107,30 @@ class SignInToProfileViewModel( data class Failed(val problem: CredentialProblem) : Outcome } + private suspend fun write(what: String, writer: suspend () -> IdentityWriter.WriteNostrCredentialResult): Outcome = try { + when (val result = writer()) { + is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e("could not write the $what", e) + Outcome.Failed(CredentialProblem.CouldNotWrite) + } + /** - * Writes the credential's secret to its store, then plants the placeholder account - * that tells the navigation machine this pubkey has a history to fetch rather than a - * profile to create. Suspends until both are done or one has failed. + * Writes the credential to its store, then plants the placeholder account that tells + * the navigation machine this pubkey has a history to fetch rather than a profile to + * create. Suspends until both are done or one has failed. The plant is idempotent by + * pubkey, which is what lets the nsec of a key held read-only sign in over it. */ suspend fun commit(credential: SignInCredential): Outcome { val written: Outcome = when (credential) { - is SignInCredential.NostrSecret -> try { - when (val result = writeNostrKey(credential.privateKey)) { - is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) - is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) - is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.e("could not write the nostr key", e) - Outcome.Failed(CredentialProblem.CouldNotWrite) - } + is SignInCredential.NostrSecret -> write("nostr key") { writeNostrKey(credential.privateKey) } + + is SignInCredential.NostrPublicKey -> write("nostr public key") { writeNostrPublicKey(credential.nostrPublicKey) } is SignInCredential.RecoveryPhrase -> { // Callback-shaped writer, awaited: the seed writer drives WritingSeedState and diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt new file mode 100644 index 00000000..cd124312 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt @@ -0,0 +1,54 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Signing in twice plants one account. + * + * Until the read-only kind nothing called `signInToProfile` twice for one pubkey: the + * writers refused a second sign-in before it got there. Pasting the nsec of a key held + * read-only is the first path that signs in a pubkey whose account already exists, and + * a second placeholder would be two kind-0 rows for one pubkey -- two accounts + * disagreeing about which is this one. `getLocalAccounts` is every kind-0 row, so the + * count is the assertion. + */ +class SignInToProfileTwiceJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val publicKey = "a".repeat(64) + private val someoneElse = "b".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `a second sign-in for the same key plants nothing, and another key still gets its own account`() = runBlocking { + repository.signInToProfile(publicKey) + repository.signInToProfile(publicKey) + repository.signInToProfile(someoneElse) + + val accounts = repository.getLocalAccounts() + + assertEquals(listOf(publicKey, someoneElse).sorted(), accounts.mapNotNull { it.unsignedNostrEvent?.pubKey }.sorted()) + val placeholder = accounts.first { it.unsignedNostrEvent?.pubKey == publicKey }.unsignedNostrEvent!! + assertEquals(GENESIS_AT, placeholder.signedAt, "the placeholder is what it was: signed at genesis, so the notary skips it") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt index e299b4d3..fd444982 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt @@ -1,8 +1,10 @@ package press.mantra.compose.identity +import press.mantra.compose.extensions.hexToNpubHrp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotEquals /** * Every row of the sign-in screen's table, in and out. @@ -72,9 +74,33 @@ class CredentialParserJvmTest { } @Test - fun `a public key is named for what it is`() { - assertEquals(CredentialProblem.PublicKeyOnly, refused(npub)) - assertEquals(CredentialProblem.PublicKeyOnly, refused("nostr:$npub")) + fun `an npub, a nostr-prefixed npub and the upper-cased form all name the same public key`() { + for (input in listOf(npub, "nostr:$npub", npub.uppercase())) { + val credential = assertIs(recognised(input), "for '$input'") + assertEquals(publicKey, credential.nostrPublicKey) + } + } + + /** + * Sixty-four hex characters are a secret and stay one: a private key and an x-only + * public key are the same size, and the parser cannot tell them apart -- nor may it + * guess, since an x coordinate is almost always also a valid scalar and the guess + * would never fire. The public key of the vector, pasted as hex, is a *different* + * secret whose npub the confirm step shows. + */ + @Test + fun `sixty-four hex characters are still a secret, even when they are a public key`() { + val credential = assertIs(recognised(publicKey)) + assertNotEquals(publicKey, credential.nostrPublicKey) + } + + @Test + fun `an npub that does not decode, or names no point on the curve, is an invalid key`() { + assertEquals(CredentialProblem.InvalidKey, refused(npub.dropLast(1) + "q")) + // The field prime, bech32-encoded under npub: sixty-four hex characters that are + // not an x coordinate. + val notOnTheCurve = "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f".hexToNpubHrp() + assertEquals(CredentialProblem.InvalidKey, refused(notOnTheCurve)) } @Test diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt index 06baab6d..94c086ef 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt @@ -31,6 +31,7 @@ class SignInToProfileViewModelJvmTest { private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey) private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, publicKey) + private val nostrPublicKey = SignInCredential.NostrPublicKey(publicKey) /** Records the order of effects; throws on anything the sign-in should not touch. */ private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { @@ -43,13 +44,35 @@ class SignInToProfileViewModelJvmTest { private fun viewModel( recorder: Recorder, nostrKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, + publicKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) }, ) = SignInToProfileViewModel( nostrRepository = recorder, writeNostrKey = { recorder.effects += "writeNostrKey"; nostrKeyOutcome() }, + writeNostrPublicKey = { recorder.effects += "writeNostrPublicKey"; publicKeyOutcome() }, writeRecoveryPhrase = { _, onWritten, onError -> recorder.effects += "writeRecoveryPhrase"; seedOutcome(onWritten, onError) }, ) + @Test + fun `a public key is written, then its account is planted, then the id comes back`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder).commit(nostrPublicKey) + + assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome) + assertEquals(listOf("writeNostrPublicKey", "signInToProfile:$publicKey"), recorder.effects) + } + + @Test + fun `the npub of a key already here is refused, and no account is planted`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder, publicKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrPublicKey) + + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(listOf("writeNostrPublicKey"), recorder.effects) + } + @Test fun `an nsec is written, then its account is planted, then the id comes back`() = runBlocking { val recorder = Recorder() From f5a6f747312928d3cc6d3554d4c4789ecc99a30a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:38:18 +0200 Subject: [PATCH 33/71] feat(ui): what a read-only identity is shown, and what it is not Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a product. One question, asked in one way. LocalCanSign is a composition local for the snackbar host's reason: screens do not receive the identity, MetadataEventDetail -- where follow and send message live -- is several composables below anything that could be handed more, and a parameter threaded through twenty-six lists is forgotten in the twenty-seventh. ProvideSigningCapability sits once above the navigation suite. The default is true rather than an error, the one way this differs from the snackbar host: the provider cannot be forgotten per screen, so the only things composed outside it are previews and tests. And it is a capability, not a kind -- "can this identity sign?", not "is this an npub?" -- so that a remote signer is not a fourth value in every when. The inventory, hidden rather than disabled because the empty state beside each says why: HomeScreen's New chat in both layouts, its sheet and its npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit profile and send message (the "follows you" state still shows -- a fact, not an action); ActiveProfileScreen's key package management and key recovery; ShareProfileScreen's re-broadcast, which signs nothing but queues a copy for the finder relays, and a read-only identity puts nothing on a relay; SocialPreconditionScreen's invite and view invites, pending today and writes when they exist; and the not-found screen's set-up form, since a kind 0 has to be signed. Everything else that writes is behind a chat room, which a read-only identity can never open. The Messages tab, for a read-only identity, is an EmptyState where the rooms would be -- one column at every width, since a two-pane layout is a list beside a detail and there is no list -- whose message says which absence it is and whose action is the upgrade: Sign in with the nsec, which opens the same screen as landing's, hits the credentials file's upgrade rule, and comes back through startup with rooms in it. ChatRoomListViewModel is not composed, so the inbox sync and the MLS negentropy it would queue are not queued. Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens under each value of LocalCanSign and looks for the controls by text, on the unmerged tree so that "does not exist" is not vacuous. The M3 audit's budgets hold. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@e31033e857878c6d62830c5fd34918dfb26abfe5 --- .../composeResources/values/strings.xml | 2 + .../ui/composable/ActiveProfileScreen.kt | 11 +- .../compose/ui/composable/HomeScreen.kt | 60 +++++- .../ui/composable/ShareProfileScreen.kt | 7 +- .../ui/composable/SocialPreconditionScreen.kt | 57 +++--- .../ui/composable/UnsyncedProfileScreen.kt | 6 + .../ui/composable/navigation/MantraNavHost.kt | 5 + .../composable/widgets/SigningCapability.kt | 47 +++++ .../widgets/detail/MetadataEventDetail.kt | 14 +- .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 178 ++++++++++++++++++ 10 files changed, 353 insertions(+), 34 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1f3907b6..593c6b04 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -265,6 +265,8 @@ This will be the display name for this chat room. This will be the display name for your profile and also important for search. Read only + Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent. + Sign in with the nsec This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index 6f4b486f..cf1920e1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -50,6 +50,7 @@ import press.mantra.compose.ui.view.model.ActiveProfileViewModel import press.mantra.compose.ui.view.state.ActiveProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -84,6 +85,9 @@ fun ActiveProfileScreen( nostrRepository: NostrRepository, ) { + // Whether this identity can sign, which decides which rows below exist. + val canSign = LocalCanSign.current + val activeProfileViewModel: ActiveProfileViewModel = viewModel( factory = ActiveProfileViewModel.factory( nostrEventId = nostrEventId, @@ -226,7 +230,10 @@ fun ActiveProfileScreen( } } - item { + // Key packages are signed, and there is nothing to recover for + // an identity the device holds no secret for: neither row for a + // read-only identity. + if (canSign) item { TextButton( onClick = { onNavigateToRoute.invoke( @@ -279,7 +286,7 @@ fun ActiveProfileScreen( } - item { + if (canSign) item { TextButton( onClick = { onNavigateToRoute.invoke( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index 2b91c937..7b2ba46a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -24,6 +25,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -45,6 +47,7 @@ import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SignInRoute import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.dialogs.NewChatBottomSheetDialog import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog @@ -74,6 +77,9 @@ import press.mantra.compose.ui.theme.breakpoint import press.mantra.compose.ui.theme.listPaneWidthFor import press.mantra.compose.ui.theme.readableContent import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here +import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_profile +import mantra.composeapp.generated.resources.sign_in_with_the_nsec +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews @@ -120,6 +126,9 @@ fun HomeScreen( } } + // Whether this identity can sign: the one thing the read-only branch below turns on. + val canSign = LocalCanSign.current + val homeScreenViewModel: HomeViewModel = viewModel( factory = HomeViewModel.factory( activeUserPublicKey = activeUserPublicKey, @@ -178,7 +187,11 @@ fun HomeScreen( // the bottom-right of the *window*, which is on top of the // transcript's send button; M3 puts a list-detail layout's primary // action in the list pane, and so does the branch below. - if (listPaneWidth == null) NewChatButton { showBottomSheet = true } + // And only for an identity that can sign: a new chat is a key + // package, a welcome and a gift wrap, none of which a read-only + // identity can produce. Hidden, not disabled -- the empty state + // beside it says why. + if (listPaneWidth == null && canSign) NewChatButton { showBottomSheet = true } } ) { innerPadding -> @Composable @@ -242,7 +255,21 @@ fun HomeScreen( } } - if (listPaneWidth == null) { + if (!canSign) { + // One column at every width. The two-pane layout is a list beside + // a detail, and there is no list: every room is MLS or a gift wrap, + // encrypted to the key this identity does not hold. The empty state + // is the only thing on the screen, which is the one case + // readableContent()'s centring is for -- and its action is where + // the upgrade lives. ChatRoomListViewModel is not composed, so the + // inbox sync and the MLS negentropy it would queue are not queued. + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.Center, + ) { + ReadOnlyInbox(onSignInWithTheNsec = { onNavigateToRoute(SignInRoute) }) + } + } else if (listPaneWidth == null) { // Compact and medium: the list is the screen, and a room is a route. // Byte for byte the layout this screen has always had. ChatRoomListPane( @@ -311,7 +338,10 @@ fun HomeScreen( } } - if (showBottomSheet) { + // Neither can open without the button above, and the button is hidden for + // an identity that cannot sign; the guard here is so that the two cannot + // drift apart. + if (showBottomSheet && canSign) { NewChatBottomSheetDialog( scope = scope, sheetState = sheetState, @@ -327,7 +357,7 @@ fun HomeScreen( } when { - openNpubDialog.value -> { + openNpubDialog.value && canSign -> { StartDirectMessageToNpubOrNip05Dialog( activeUserPublicKey = activeUserPublicKey, scope = scope, @@ -394,6 +424,28 @@ It has survived not only five centuries, but also the leap into electronic types } } +/** + * What a read-only identity sees where its rooms would be, and the one thing that fills + * it. + * + * `EmptyState`'s message is required for exactly this reason -- an absence has to say + * which absence it is -- and its action slot is where the upgrade lives: the nsec pasted + * on the sign-in screen hits the credentials file's upgrade rule, and startup takes the + * user back here with rooms in it. + */ +@Composable +private fun ReadOnlyInbox(onSignInWithTheNsec: () -> Unit) { + EmptyState( + message = stringResource(Res.string.messages_need_the_secret_key_this_profile), + icon = Icons.Default.Lock, + action = { + TextButton(onClick = onSignInWithTheNsec) { + Text(stringResource(Res.string.sign_in_with_the_nsec)) + } + }, + ) +} + /** * The one action the chat list offers, in whichever slot the layout has for it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt index fc53f059..c559cac0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt @@ -41,6 +41,7 @@ import press.mantra.compose.ui.view.model.ShareProfileViewModel import press.mantra.compose.ui.view.state.ShareProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -176,7 +177,11 @@ fun ShareProfileScreen( } } - Row( + // Nothing is signed by a re-broadcast, but a copy of the kind 0 is + // queued for the finder relays, and a read-only identity puts nothing + // on a relay -- not even a copy. The broadcast pump is not running for + // it to carry the request anyway. + if (LocalCanSign.current) Row( modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalArrangement = Arrangement.Center ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt index ab33152d..936fb60d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -88,36 +89,40 @@ fun SocialPreconditionScreen( modifier = Modifier.weight(1f) ) - Text( - stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays), - textAlign = TextAlign.Center - ) - - Button( - onClick = { - onNavigateToInviteFriend.invoke() - } - ) { + // Both pending today, and both writes when they exist: an invite is sent, + // an acceptance signed. A read-only identity is offered only the way past. + if (LocalCanSign.current) { Text( - stringResource(Res.string.invite_a_friend) + stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays), + textAlign = TextAlign.Center ) - } - Spacer( - modifier = Modifier.weight(1f) - ) - - Text( - stringResource(Res.string.view_and_accept_invites_you_may_have), - textAlign = TextAlign.Center - ) - - Button( - onClick = { - onNavigateToViewInvites.invoke() + Button( + onClick = { + onNavigateToInviteFriend.invoke() + } + ) { + Text( + stringResource(Res.string.invite_a_friend) + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + stringResource(Res.string.view_and_accept_invites_you_may_have), + textAlign = TextAlign.Center + ) + + Button( + onClick = { + onNavigateToViewInvites.invoke() + } + ) { + Text(stringResource(Res.string.view_invites)) } - ) { - Text(stringResource(Res.string.view_invites)) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 7a86f4be..6223c329 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -49,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @@ -181,6 +182,11 @@ private fun NotFound( style = MaterialTheme.typography.bodyMedium, ) + // Setting a profile up is signing a kind 0, which a read-only identity cannot do. + // The form is not offered to it; the way out for that kind is Phase 6 of + // docs/npub-sign-in.md. + if (!LocalCanSign.current) return@Column + Text( text = stringResource(Res.string.set_up_a_profile), style = MaterialTheme.typography.titleMedium, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 45da90f4..7ce0f604 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -124,6 +124,7 @@ import press.mantra.compose.ui.view.state.NavigationUIState import press.mantra.compose.ui.view.state.NostrEventDetailUIState import press.mantra.compose.ui.view.state.SearchUIState import press.mantra.compose.ui.theme.NavigationMotion +import press.mantra.compose.ui.composable.widgets.ProvideSigningCapability import press.mantra.compose.ui.theme.breakpoint import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal @@ -430,6 +431,9 @@ fun MantraNavHost( } } + // Once, above every screen: what a read-only identity may not be offered is decided + // where the control is drawn, and this is how the control finds out. + ProvideSigningCapability(sovereignWalletViewModel.activeIdentity) { MantraNavigationSuite( navController = navController, breakpoint = MaterialTheme.breakpoint, @@ -1655,5 +1659,6 @@ fun MantraNavHost( } } } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt new file mode 100644 index 00000000..96127bc5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/SigningCapability.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.identity.Identity + +/** + * Whether the identity the app is running as can sign -- the one question every write + * entrance asks before it is drawn. + * + * A composition local rather than a parameter for the snackbar host's reason: screens + * do not receive the identity, `MetadataEventDetail` -- where *follow* and *send message* + * live -- is several composables below anything that could be handed more, and a + * parameter threaded through twenty-six lists is forgotten in the twenty-seventh. + * [ProvideSigningCapability] sits once, above every screen, in the nav host. + * + * The default is `true` rather than an error, and that is the one way this differs from + * [LocalSnackbarHostState]: the provider cannot be forgotten per screen, so the only + * things composed outside it are previews and tests, and those should render as they + * always have. + * + * A capability, not a kind. A screen asks "can this identity sign?", not "is this an + * npub?", because the answer is what it needs -- and because a remote signer, which can + * sign and holds no local key, would otherwise be a fourth value in every `when`. See + * docs/npub-sign-in.md, Phase 5. + */ +val LocalCanSign: ProvidableCompositionLocal = staticCompositionLocalOf { true } + +/** Provides [LocalCanSign] from the active identity for everything inside [content]. */ +@Composable +fun ProvideSigningCapability( + activeIdentity: StateFlow, + content: @Composable () -> Unit, +) { + val identity by activeIdentity.collectAsState() + // No identity yet -- startup, landing, sign-in -- is not read-only; it is nobody. Those + // screens have no write entrances to hide, and `true` keeps them drawn as they were. + val canSign = identity?.canSign ?: true + CompositionLocalProvider(LocalCanSign provides canSign) { + content() + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index d8c0c463..424efa41 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt @@ -54,6 +54,7 @@ import press.mantra.compose.ui.view.model.MetadataEventDetailViewModel import press.mantra.compose.ui.view.state.MetadataEventDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -83,6 +84,9 @@ fun MetadataEventDetail( ) { val scope = rememberCoroutineScope() + // Whether this identity can sign, which decides which of the controls below exist. + val canSign = LocalCanSign.current + val profile = localNostrEvent.profile val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) @@ -178,6 +182,12 @@ fun MetadataEventDetail( if (metadataEventDetailViewModel.isActionPending.value) { LoadingIndicator() + } else if (!canSign) { + // Edit profile is a kind 0, follow and unfollow a + // kind 3: none of them can be signed by a read-only + // identity, so none is offered. The "follows you" + // state below still shows; it is a fact, not an + // action. } else { if (metadataEventDetailUIState.isActiveUser) { Button( @@ -280,7 +290,9 @@ fun MetadataEventDetail( } } - item { + // A message is a gift wrap, sealed with the sender's key. Not for a + // read-only identity. + if (canSign) item { Button( onClick = { onNavigateToChatRoom.invoke( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt new file mode 100644 index 00000000..a6e95041 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -0,0 +1,178 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ActiveProfileUIState +import press.mantra.compose.ui.view.state.HomeScreenUIState +import kotlin.test.Test + +/** + * The inventory in Phase 5 of docs/npub-sign-in.md, as assertions. + * + * Every write entrance one tap from the three tabs has a row there, and a row without an + * assertion is a row that will regress: the compiler cannot say whether the inventory is + * complete, since `LocalCanSign` is read where a control is drawn and nothing forces a + * control to read it. So each screen is composed twice, once as an identity that can + * sign and once as one that cannot, and the controls are looked for by their text. + * + * The repositories are the no-op ones with the reads each screen makes delegated to a + * fixed answer, as `ChatPaneLayoutJvmTest` does. + */ +@OptIn(ExperimentalTestApi::class) +class ReadOnlyEntrancesJvmTest { + + private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" + private val eventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899" + + // --- Home: the room list, or the empty state with the upgrade --- + + @Test + fun `an identity that can sign is offered a new chat, and no upgrade`() = runDesktopComposeUiTest(400, 900) { + setContent { AsIdentity(canSign = true) { Home() } } + + onNodeWithText("New chat", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a read-only identity is offered the upgrade, and no new chat`() = runDesktopComposeUiTest(400, 900) { + setContent { AsIdentity(canSign = false) { Home() } } + + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("New chat", useUnmergedTree = true).assertDoesNotExist() + } + + /** One column at every width: there is no list to put a detail beside. */ + @Test + fun `a read-only identity in a large window still has one column`() = runDesktopComposeUiTest(1400, 900) { + setContent { AsIdentity(canSign = false) { Home() } } + + onNodeWithText("Sign in with the nsec", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("New chat", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Pick a conversation to read it here.", useUnmergedTree = true).assertDoesNotExist() + } + + // --- Profile: key packages and key recovery, or neither --- + + @Test + fun `an identity that can sign is offered key recovery and key packages`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { ActiveProfile() } } + + onNodeWithText("Key recovery", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Key package management", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a read-only identity is offered neither, and can still sign out`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ActiveProfile() } } + + onNodeWithText("Key recovery", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Key package management", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + } + + // --- harness --- + + @Composable + private fun AsIdentity(canSign: Boolean, content: @Composable () -> Unit) { + MantraTheme { + ProvideSnackbarHost { + CompositionLocalProvider(LocalCanSign provides canSign) { + content() + } + } + } + } + + @Composable + private fun Home() { + HomeScreen( + activeUserPublicKey = publicKey, + initialHomeScreenUIState = HomeScreenUIState.Loaded(profileWithFollowing = profile), + onNavigateToRoute = {}, + onNavigateToDirectMessageDetail = {}, + onNavigateToChatRoomCreation = {}, + nostrRepository = FixedProfile, + chatRepository = NoRooms, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + ) + } + + @Composable + private fun ActiveProfile() { + ActiveProfileScreen( + initialActiveProfileUIState = ActiveProfileUIState.Loaded( + localNostrEvent = LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile), + ), + activeUserPublicKey = publicKey, + nostrEventId = eventId, + onNavigateBack = {}, + onNavigateToRoute = {}, + nostrRepository = FixedProfile, + ) + } + + private val metadataEvent = NostrEvent( + id = eventId, + pubKey = publicKey, + content = """{"name":"Reader"}""", + tags = emptyArray(), + sig = "", + kind = MetadataEvent.KIND, + ) + + private val profile = LocalProfileWithFollowing( + nostrEvent = NostrEvent( + id = eventId, + pubKey = publicKey, + content = "", + tags = emptyArray(), + sig = "", + kind = TextNoteEvent.KIND, + ), + profile = Profile( + publicKey = publicKey, + displayName = "Reader", + nostrEventId = eventId, + ), + following = emptyList(), + ) + + private val FixedProfile = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithFollowing( + publicKey: String, + ): Flow = flowOf(profile) + + override suspend fun observeLocalNostrEventById( + nostrEventId: String, + ): Flow = flowOf(LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile)) + } + + private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeChatRoomListByPublicKey( + publicKey: String, + ): Flow> = flowOf(emptyList()) + } +} From 786ac159597b9c4f5ecf9aec3ff94fef3206d920 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:45:34 +0200 Subject: [PATCH 34/71] feat(identity): two exits for an identity that holds no key Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors. ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out -- the credential out of the file, the metadata hidden, the account rows gone, in that order so a failure partway leaves the credential on disk rather than an identity the selector lists but nothing can open. Since the credentials file the first step is the same call for both credential kinds, so it is one function; a mnemonic identity is refused at the first step, because removing a seed is a wallet question this does not answer. The nsec view model calls it now and keeps only its own state. Sign out on the profile tab does, for a read-only identity only, what its colour has been promising: a confirmation naming the npub -- this device holds no key for it, so there is nothing to lose; the profile stays on the relays -- then the identity leaves the device and the nav host's tail re-lists and clears the active identity, which shows the selector or, if this was the last one, Landing. It is the first real sign out in the app. For the other two kinds the button keeps its pending route. SignOutViewModel owns the confirmation and the in-flight state; SignOutDependencies bundles what the screen needs so that a caller with none of it -- previews, tests -- passes nothing. The not-found screen had, for a read-only identity, no exit: Phase 5 hid the set-up form (a kind 0 has to be signed), the profile tab is not reachable before ProfileLoaded, and a user whose npub was found on no relay could try again for ever. So a third action, shown only where the second is not: use a different key, which is the same sequence behind the same dialog, reached from the other end of the identity's life. Tests: the sequence and where it stops, the view model's states around it, and the two screens composed as each kind -- the profile's sign out asking first and naming the npub, the not-found screen offering the form to one kind and the other key to the other. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@a586b7c116529f01a4414e40b0eb41f5dff419cc --- .../composeResources/values/strings.xml | 4 + .../mantra/compose/identity/ForgetIdentity.kt | 50 ++++++++ .../ui/composable/ActiveProfileScreen.kt | 72 ++++++++++- .../ui/composable/UnsyncedProfileScreen.kt | 60 ++++++++- .../ui/composable/navigation/MantraNavHost.kt | 23 +++- .../dialogs/SignOutOfReadOnlyDialog.kt | 43 +++++++ .../ui/view/model/NostrSecretViewModel.kt | 24 ++-- .../compose/ui/view/model/SignOutViewModel.kt | 106 ++++++++++++++++ .../compose/identity/ForgetIdentityJvmTest.kt | 95 +++++++++++++++ .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 114 +++++++++++++++++- .../ui/view/model/SignOutViewModelJvmTest.kt | 106 ++++++++++++++++ 11 files changed, 678 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 593c6b04..de74e248 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -267,6 +267,10 @@ Read only Messages need the secret key. This profile is read only: you can see it and the people it follows, but nothing here can be opened or sent. Sign in with the nsec + Sign out of this profile? + Use a different key? + This device holds no key for %1$s, so there is nothing to lose. The profile stays on the relays, and you can sign in again any time. + Could not sign out. Please try again. This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt new file mode 100644 index 00000000..d37c2c03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt @@ -0,0 +1,50 @@ +package press.mantra.compose.identity + +import press.mantra.compose.repository.NostrRepository + +/** + * Takes an identity off the device: the credential out of the file, its preference + * files deleted, its metadata hidden, its account rows gone -- in that order, so a + * failure partway leaves the credential on disk rather than an identity the selector + * lists but nothing can open. The caller then re-lists and clears the active identity. + * + * Lifted out of `NostrSecretViewModel.forgetKey`, where it was written for an nsec, so + * that the two exits a read-only identity has -- sign out, and *use a different key* on + * the not-found screen -- run the same sequence rather than a copy of it. The first step + * is the same call for both credential kinds since the credentials file; only a mnemonic + * identity is refused, because removing a seed is a wallet question this does not + * answer. + * + * The two writers are injected as functions so the sequence can be pinned in a test + * without a key store, as the nsec view model's already is. + */ +object ForgetIdentity { + + sealed interface Outcome { + /** The device no longer holds anything for the identity. */ + data object Forgotten : Outcome + + /** A mnemonic identity: its key is in the seed, and this does not remove seeds. */ + data object NotACredential : Outcome + + /** The credentials file could not be read; nothing was changed. */ + data object CannotForget : Outcome + } + + suspend fun forget( + identity: Identity, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): Outcome = when (forgetNostrCredential(identity)) { + is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> { + hideIdentityMetadata(identity) + // The kind 0 that made it a local account, and anything queued that can now + // never be signed. Published events and the profile cache stay. + nostrRepository.forgetLocalAccount(identity.nostrPublicKey) + Outcome.Forgotten + } + is IdentityWriter.ForgetNostrCredentialResult.NotACredential -> Outcome.NotACredential + is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> Outcome.CannotForget + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index cf1920e1..f467485c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -62,6 +62,16 @@ import mantra.composeapp.generated.resources.profile import mantra.composeapp.generated.resources.key_recovery import mantra.composeapp.generated.resources.share_profile import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.sign_out_of_this_profile_question +import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.view.model.SignOutViewModel +import kotlinx.coroutines.flow.StateFlow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please import press.mantra.compose.ui.composable.widgets.ErrorState @@ -83,11 +93,42 @@ fun ActiveProfileScreen( onNavigateBack: () -> Unit, onNavigateToRoute: (Route) -> Unit, nostrRepository: NostrRepository, + // Sign out is real for a read-only identity only -- there is nothing on the device + // to lose -- and these are what it takes. Null where the screen is composed without + // an identity to sign out of (previews, tests), in which case the button keeps its + // pending route for every kind. + signOut: SignOutDependencies? = null, ) { // Whether this identity can sign, which decides which rows below exist. val canSign = LocalCanSign.current + val signOutViewModel: SignOutViewModel? = signOut?.let { + viewModel( + factory = SignOutViewModel.factory( + activeIdentityStateFlow = it.activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = it.forgetNostrCredential, + hideIdentityMetadata = it.hideIdentityMetadata, + ), + ) + } + val signOutState = signOutViewModel?.state?.collectAsState()?.value + if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) { + SignOutOfReadOnlyDialog( + title = Res.string.sign_out_of_this_profile_question, + nostrPublicKey = signOutViewModel.nostrPublicKey, + onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) }, + onDismiss = { signOutViewModel.cancel() }, + ) + } + // The failure is short and needs no action: the button is still there to try again. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again) + LaunchedEffect(signOutState) { + if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut) + } + val activeProfileViewModel: ActiveProfileViewModel = viewModel( factory = ActiveProfileViewModel.factory( nostrEventId = nostrEventId, @@ -339,14 +380,26 @@ fun ActiveProfileScreen( // destructive action. It now matches how leaving and // deleting a group are already treated in // ChatRoomDetailScreen: a TextButton in the error colour. + // + // For a read-only identity it does what its colour has been + // promising: a confirmation naming the npub, then the + // identity leaves the device. For the other two kinds it + // keeps routing to the pending screen -- removing a seed is + // a wallet question, and an nsec has its own forget under + // key recovery. TextButton( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ), + enabled = signOutState !is SignOutViewModel.State.InProgress, onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Sign out") - ) + if (!canSign && signOutViewModel != null) { + signOutViewModel.askToSignOut() + } else { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Sign out") + ) + } } ) { Icon( @@ -387,6 +440,19 @@ fun ActiveProfileScreen( } } +/** + * What signing out of a read-only identity needs, bundled so that the screen's parameter + * list gains one line rather than four, and so a caller that has none of them passes + * nothing rather than three lambdas that throw. + */ +class SignOutDependencies( + val activeIdentityStateFlow: StateFlow, + val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + val hideIdentityMetadata: suspend (Identity) -> Unit, + /** The nav host's tail: re-list, and clear the active identity so startup shows the selector or Landing. */ + val onSignedOut: () -> Unit, +) + @ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 6223c329..69c81a63 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -41,6 +41,14 @@ import mantra.composeapp.generated.resources.it_may_be_new_or_it_may_live_on_rel import mantra.composeapp.generated.resources.name_eg_alan_turing import mantra.composeapp.generated.resources.set_up_a_profile import mantra.composeapp.generated.resources.try_again +import mantra.composeapp.generated.resources.use_a_different_key +import mantra.composeapp.generated.resources.use_a_different_key_question +import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again +import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.view.model.SignOutViewModel +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key @@ -70,6 +78,10 @@ import press.mantra.compose.ui.view.state.UnsyncedProfileUIState fun UnsyncedProfileScreen( unsyncedProfilePublicKey: String, nostrRepository: NostrRepository, + // The not-found state's exit for an identity that cannot set a profile up: the same + // act as signing out, reached from the other end of the identity's life. Null where + // there is no identity to leave (previews), in which case that kind has no exit here. + signOut: SignOutDependencies? = null, ) { val viewModel: UnsyncedProfileViewModel = viewModel( factory = UnsyncedProfileViewModel.factory( @@ -79,6 +91,31 @@ fun UnsyncedProfileScreen( ) val uiState by viewModel.uiState.collectAsState() + val signOutViewModel: SignOutViewModel? = signOut?.let { + viewModel( + factory = SignOutViewModel.factory( + activeIdentityStateFlow = it.activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = it.forgetNostrCredential, + hideIdentityMetadata = it.hideIdentityMetadata, + ), + ) + } + val signOutState = signOutViewModel?.state?.collectAsState()?.value + if (signOutViewModel != null && signOut != null && signOutState is SignOutViewModel.State.Confirming) { + SignOutOfReadOnlyDialog( + title = Res.string.use_a_different_key_question, + nostrPublicKey = signOutViewModel.nostrPublicKey, + onConfirm = { signOutViewModel.signOut(onSignedOut = signOut.onSignedOut) }, + onDismiss = { signOutViewModel.cancel() }, + ) + } + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotSignOut = stringResource(Res.string.could_not_sign_out_please_try_again) + LaunchedEffect(signOutState) { + if (signOutState is SignOutViewModel.State.Failed) notify(couldNotSignOut) + } + // imePadding for the not-found form's fields. Scaffold( modifier = Modifier.imePadding(), @@ -94,6 +131,7 @@ fun UnsyncedProfileScreen( is UnsyncedProfileUIState.NotFound -> NotFound( viewModel = viewModel, unsignedNostrEventId = state.unsignedNostrEventId, + onUseADifferentKey = signOutViewModel?.let { { it.askToSignOut() } }, ) } } @@ -150,6 +188,8 @@ private fun Searching() { private fun NotFound( viewModel: UnsyncedProfileViewModel, unsignedNostrEventId: Long, + /** Shown only where the set-up form is not; null when there is no identity to leave. */ + onUseADifferentKey: (() -> Unit)?, ) { val nameField = viewModel.formState.nameField.textFieldState val biographyField = viewModel.formState.biographyField.textFieldState @@ -183,9 +223,23 @@ private fun NotFound( ) // Setting a profile up is signing a kind 0, which a read-only identity cannot do. - // The form is not offered to it; the way out for that kind is Phase 6 of - // docs/npub-sign-in.md. - if (!LocalCanSign.current) return@Column + // The form is not offered to it -- and then this state would have no exit: the + // profile tab is not reachable before ProfileLoaded, so a user whose npub was not + // found on any relay could try again for ever. So, for that kind only, the third + // action: the identity leaves the device, and the way back is the selector or + // Landing. + if (!LocalCanSign.current) { + if (onUseADifferentKey != null) { + TextButton( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = onUseADifferentKey, + ) { + Text(text = stringResource(Res.string.use_a_different_key)) + } + } + return@Column + } Text( text = stringResource(Res.string.set_up_a_profile), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 7ce0f604..c70940d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -124,6 +124,7 @@ import press.mantra.compose.ui.view.state.NavigationUIState import press.mantra.compose.ui.view.state.NostrEventDetailUIState import press.mantra.compose.ui.view.state.SearchUIState import press.mantra.compose.ui.theme.NavigationMotion +import press.mantra.compose.ui.composable.SignOutDependencies import press.mantra.compose.ui.composable.widgets.ProvideSigningCapability import press.mantra.compose.ui.theme.breakpoint import co.touchlab.kermit.Logger @@ -431,6 +432,24 @@ fun MantraNavHost( } } + // What signing out of a read-only identity takes, for the two screens that offer it. + // The tail is the nsec forget's: the device no longer holds anything for the + // identity, so re-list -- the selector drops it -- then clear the active identity, + // and the navigation observer sends a null identity to startup, which shows the + // selector or, if this was the last one, Landing. + val signOutOfReadOnlyIdentity = remember(sovereignWalletViewModel) { + SignOutDependencies( + activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, + forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, + hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, + onSignedOut = { + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.resetToSelector() + } + }, + ) + } + // Once, above every screen: what a read-only identity may not be offered is decided // where the control is drawn, and this is how the control finds out. ProvideSigningCapability(sovereignWalletViewModel.activeIdentity) { @@ -747,6 +766,7 @@ fun MantraNavHost( UnsyncedProfileScreen( unsyncedProfilePublicKey = route.publicKey, nostrRepository = databaseNostrRepository, + signOut = signOutOfReadOnlyIdentity, ) } composable { backStackEntry -> @@ -847,7 +867,8 @@ fun MantraNavHost( navController.navigate( route = route ) - } + }, + signOut = signOutOfReadOnlyIdentity, ) } composable { backStackEntry -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt new file mode 100644 index 00000000..7c0fdd7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/SignOutOfReadOnlyDialog.kt @@ -0,0 +1,43 @@ +package press.mantra.compose.ui.composable.widgets.dialogs + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.cancel +import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.this_device_holds_no_key_for_s_so_there +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.ui.composable.widgets.Decorative + +/** + * The confirmation before a read-only identity leaves the device, naming the npub so + * the user sees which one. Shared by the profile tab's *sign out* and the not-found + * screen's *use a different key*, which differ only in what the title calls it. + */ +@Composable +fun SignOutOfReadOnlyDialog( + title: StringResource, + nostrPublicKey: String?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Logout, contentDescription = Decorative) }, + title = { Text(stringResource(title)) }, + text = { Text(stringResource(Res.string.this_device_holds_no_key_for_s_so_there, nostrPublicKey?.hexToNpubHrp() ?: "")) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(Res.string.sign_out)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt index ff121980..88bce8c0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import press.mantra.compose.extensions.hexToNsecHrp +import press.mantra.compose.identity.ForgetIdentity import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.IdentityWriter @@ -46,8 +47,9 @@ import press.mantra.compose.ui.view.state.NostrSecretUIState * secret it is. * * It also owns the inverse of an import: [forgetKey], which removes the key from the - * device and the account with it. Only for a bare key; a mnemonic identity's key is in - * the seed, and removing a seed is a wallet question this does not answer. + * device and the account with it, through [ForgetIdentity]. Only for a bare key; a + * mnemonic identity's key is in the seed, and removing a seed is a wallet question this + * does not answer. */ class NostrSecretViewModel( val phoenixGlobal: PhoenixGlobal, @@ -163,9 +165,9 @@ class NostrSecretViewModel( /** * Key out of the file, preferences deleted, metadata hidden, account rows gone -- in - * that order, so a failure partway leaves the key on disk rather than an identity the - * selector lists but nothing can open. [onForgotten] runs on the main thread once the - * device no longer holds the key; the caller clears the active identity and re-lists. + * that order, for the reason [ForgetIdentity] gives. [onForgotten] runs on the main + * thread once the device no longer holds the key; the caller clears the active + * identity and re-lists. */ fun forgetKey(onForgotten: () -> Unit) { val identity = activeIdentityStateFlow.value ?: return @@ -177,15 +179,15 @@ class NostrSecretViewModel( logger.e("could not forget the key", throwable) _forgetting.value = NostrSecretUIState.Forgetting.Failed }) { - when (forgetNostrCredential(identity)) { - is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> { - hideIdentityMetadata(identity) - nostrRepository.forgetLocalAccount(identity.nostrPublicKey) + // The sequence is ForgetIdentity's, shared with the two exits a read-only + // identity has; only the state it drives is this screen's. + when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) { + is ForgetIdentity.Outcome.Forgotten -> { _forgetting.value = NostrSecretUIState.Forgetting.Idle withContext(Dispatchers.Main) { onForgotten() } } - is IdentityWriter.ForgetNostrCredentialResult.NotACredential, - is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> { + is ForgetIdentity.Outcome.NotACredential, + is ForgetIdentity.Outcome.CannotForget -> { _forgetting.value = NostrSecretUIState.Forgetting.Failed } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt new file mode 100644 index 00000000..29773e92 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.ForgetIdentity +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.repository.NostrRepository + +/** + * Signing out of a read-only identity, which is forgetting it: there is nothing on the + * device to lose, so it is the first real sign out in the app. The general case -- a + * seed, with funds behind it -- stays pending, for the reason docs/nsec-sign-in.md gives. + * + * Two screens drive this with the same state: the profile tab's *sign out*, and the + * not-found screen's *use a different key*, which is the same act reached from the other + * end of the identity's life. The sequence is [ForgetIdentity]'s; this owns only the + * confirmation and the in-flight state, the way `NostrSecretViewModel` does for an nsec. + */ +class SignOutViewModel( + private val activeIdentityStateFlow: StateFlow, + private val nostrRepository: NostrRepository, + private val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + private val hideIdentityMetadata: suspend (Identity) -> Unit, +) : ViewModel() { + + sealed interface State { + data object Idle : State + data object Confirming : State + data object InProgress : State + data object Failed : State + } + + private val logger = Logger.withTag(TAG) + + private val _state = MutableStateFlow(State.Idle) + val state = _state.asStateFlow() + + /** The npub the confirmation names, so the user sees which identity is leaving. */ + val nostrPublicKey: String? get() = activeIdentityStateFlow.value?.nostrPublicKey + + fun askToSignOut() { + if (_state.value is State.InProgress) return + _state.value = State.Confirming + } + + fun cancel() { + if (_state.value is State.InProgress) return + _state.value = State.Idle + } + + /** [onSignedOut] runs on the main thread once the device holds nothing for the identity. */ + fun signOut(onSignedOut: () -> Unit) { + val identity = activeIdentityStateFlow.value ?: return + if (_state.value is State.InProgress) return + _state.value = State.InProgress + + viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + logger.e("could not sign out", throwable) + _state.value = State.Failed + }) { + when (ForgetIdentity.forget(identity, nostrRepository, forgetNostrCredential, hideIdentityMetadata)) { + is ForgetIdentity.Outcome.Forgotten -> { + _state.value = State.Idle + withContext(Dispatchers.Main) { onSignedOut() } + } + is ForgetIdentity.Outcome.NotACredential, + is ForgetIdentity.Outcome.CannotForget -> { + _state.value = State.Failed + } + } + } + } + + companion object { + private const val TAG = "SignOutViewModel" + + fun factory( + activeIdentityStateFlow: StateFlow, + nostrRepository: NostrRepository, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, + hideIdentityMetadata: suspend (Identity) -> Unit, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SignOutViewModel( + activeIdentityStateFlow = activeIdentityStateFlow, + nostrRepository = nostrRepository, + forgetNostrCredential = forgetNostrCredential, + hideIdentityMetadata = hideIdentityMetadata, + ) + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt new file mode 100644 index 00000000..e746653e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt @@ -0,0 +1,95 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The sequence, and where it stops. + * + * Forgetting is four effects in an order that matters -- the credential out of the + * file, the metadata hidden, the account rows gone, then the caller's tail -- so that a + * failure partway leaves the credential on disk rather than an identity the selector + * lists but nothing can open. A refusal at the first step must leave the other two + * untouched: an identity that is still on the device must still have its account. + * + * The two writers are recorded rather than run; `IdentityWriterJvmTest` covers what + * they do to the disk. + */ +class ForgetIdentityJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("04".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun readOnlyIdentity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount:$publicKey" + } + } + + private suspend fun forget(recorder: Recorder, result: IdentityWriter.ForgetNostrCredentialResult) = + ForgetIdentity.forget( + identity = readOnlyIdentity(), + nostrRepository = recorder, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; result }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `the credential comes out first, then the metadata, then the account`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + + assertEquals(ForgetIdentity.Outcome.Forgotten, outcome) + assertEquals( + listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount:${privateKey.nostrPublicKeyHex()}"), + recorder.effects, + ) + } + + @Test + fun `a mnemonic identity is refused before anything else is touched`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.NotACredential) + + assertEquals(ForgetIdentity.Outcome.NotACredential, outcome) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } + + @Test + fun `a store that cannot be read stops the sequence at its first step`() = runBlocking { + val recorder = Recorder() + + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) + + assertEquals(ForgetIdentity.Outcome.CannotForget, outcome) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt index a6e95041..2dfc62ac 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -3,9 +3,29 @@ package press.mantra.compose.ui.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.flow.MutableStateFlow +import okio.Path.Companion.toPath +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.model.intermdiate.LocalAccount +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.nostr.SignInSync +import java.nio.file.Files +import kotlin.time.Instant import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.flow.Flow @@ -92,8 +112,99 @@ class ReadOnlyEntrancesJvmTest { onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() } + // --- Profile: sign out is real for a read-only identity, and confirms first --- + + @Test + fun `a read-only identity's sign out asks before it acts, naming the npub`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ActiveProfile(signOut = signOutDependencies) } } + + onNodeWithText("Sign out", useUnmergedTree = true).performClick() + + onNodeWithText("Sign out of this profile?", useUnmergedTree = true).assertIsDisplayed() + // The screen shows the npub once already; the dialog names it a second time. + onAllNodesWithText(publicKey.hexToNpubHrp(), substring = true, useUnmergedTree = true).assertCountEquals(2) + onNodeWithText("nothing to lose", substring = true, useUnmergedTree = true).assertIsDisplayed() + } + + // --- Not found: set one up, or use a different key --- + + @Test + fun `an identity that can sign is offered the set-up form when its profile is not found`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { NotFound() } } + + onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed() + // Heading and button both say it; either is the form. + onAllNodesWithText("Set up a profile", useUnmergedTree = true).onFirst().assertIsDisplayed() + onNodeWithText("Use a different key", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a read-only identity is offered a different key instead, and it confirms first`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { NotFound() } } + + onNodeWithText("Try again", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Set up a profile", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed() + + onNodeWithText("Use a different key", useUnmergedTree = true).performClick() + + onNodeWithText("Use a different key?", useUnmergedTree = true).assertIsDisplayed() + } + // --- harness --- + private val readOnlyIdentity = MutableStateFlow( + Identity.readOnly( + id = WalletId("ab".repeat(20)), + nostrPublicKey = publicKey, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("user", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { Files.createTempFile("internal", ".preferences_pb").also { it.toFile().delete() }.toString().toPath() }), + ) + ) + + /** Never run in these tests: the dialogs are looked at, not confirmed. */ + private val signOutDependencies = SignOutDependencies( + activeIdentityStateFlow = readOnlyIdentity, + forgetNostrCredential = { error("not reached") }, + hideIdentityMetadata = { error("not reached") }, + onSignedOut = { error("not reached") }, + ) + + @Composable + private fun NotFound() { + UnsyncedProfileScreen( + unsyncedProfilePublicKey = publicKey, + nostrRepository = NotFoundAccount, + signOut = signOutDependencies, + ) + } + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + /** An account whose one sign-in request has finished with nothing: the not-found state. */ + private val NotFoundAccount = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeLocalAccount(publicKey: String): Flow = flowOf( + LocalAccount( + unsignedNostrEvent = UnsignedNostrEvent( + id = 1, pubKey = publicKey, kind = 0, tags = emptyArray(), content = "{}", + signedAt = GENESIS_AT, createdAt = at, updatedAt = at, savedAt = at, + ), + nostrEvent = null, + profile = null, + broadcastNostrEventRequest = null, + broadcastNostrEventReceipt = null, + synchronizeNostrEventRequests = listOf( + SynchronizeNostrEventRequest( + id = "req", purpose = SignInSync.PURPOSE, status = SignInSync.STATUS_COMPLETE, + relayURL = "wss://relay.example", level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(authors = arrayOf(publicKey), kinds = SignInSync.KINDS)), + unsignedNostrEventId = 1, createdAt = at, updatedAt = at, + ) + ), + ) + ) + } + @Composable private fun AsIdentity(canSign: Boolean, content: @Composable () -> Unit) { MantraTheme { @@ -121,7 +232,7 @@ class ReadOnlyEntrancesJvmTest { } @Composable - private fun ActiveProfile() { + private fun ActiveProfile(signOut: SignOutDependencies? = null) { ActiveProfileScreen( initialActiveProfileUIState = ActiveProfileUIState.Loaded( localNostrEvent = LocalNostrEvent(nostrEvent = metadataEvent, profile = profile.profile), @@ -131,6 +242,7 @@ class ReadOnlyEntrancesJvmTest { onNavigateBack = {}, onNavigateToRoute = {}, nostrRepository = FixedProfile, + signOut = signOut, ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt new file mode 100644 index 00000000..2dabb167 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignOutViewModelJvmTest.kt @@ -0,0 +1,106 @@ +package press.mantra.compose.ui.view.model + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.repository.NostrRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Sign out for a read-only identity: the confirmation, the in-flight state, and the + * caller being told only once the device holds nothing for the identity. The sequence + * itself is `ForgetIdentity`'s and is pinned there; this is the state around it, the + * way `NostrSecretViewModelJvmTest` pins the nsec screen's. + */ +class SignOutViewModelJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val privateKey = PrivateKey(ByteVector32("05".repeat(32))) + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun identity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + + private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + override suspend fun forgetLocalAccount(publicKey: HexKey) { + effects += "forgetLocalAccount" + } + } + + private fun viewModel(recorder: Recorder, outcome: IdentityWriter.ForgetNostrCredentialResult) = SignOutViewModel( + activeIdentityStateFlow = MutableStateFlow(identity()), + nostrRepository = recorder, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; outcome }, + hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, + ) + + @Test + fun `asking, then confirming, forgets and tells the caller, and the state is idle again`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + val told = CompletableDeferred() + + assertEquals(SignOutViewModel.State.Idle, viewModel.state.value) + viewModel.askToSignOut() + assertEquals(SignOutViewModel.State.Confirming, viewModel.state.value) + assertEquals(privateKey.nostrPublicKeyHex(), viewModel.nostrPublicKey, "the confirmation names the identity") + + viewModel.signOut { told.complete(Unit) } + withTimeout(10_000) { told.await() } + + assertEquals(listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount"), recorder.effects) + withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Idle } } + } + + @Test + fun `cancelling from the confirmation touches nothing`() { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) + + viewModel.askToSignOut() + viewModel.cancel() + + assertEquals(SignOutViewModel.State.Idle, viewModel.state.value) + assertEquals(emptyList(), recorder.effects) + } + + @Test + fun `a failure is a state, the caller is not told, and nothing after the first step ran`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) + var told = false + + viewModel.askToSignOut() + viewModel.signOut { told = true } + withTimeout(10_000) { viewModel.state.first { it == SignOutViewModel.State.Failed } } + + assertEquals(false, told) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) + } +} From c53a6f77cc702611c3ff0f3450a512f63425b578 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:51:16 +0200 Subject: [PATCH 35/71] test(identity): the preview end to end, with nothing signed, and the upgrade Phase 7 of docs/npub-sign-in.md. NpubPreviewRoundTripJvmTest is the nsec round trip with the repository made real: an in-memory database under the app's own DAOs, because the assertion this exists for is about what is not in it. An npub is signed in the way the sign-in screen does it, listed the way startup does, activated as a read-only identity, and handed to NavigationViewModel: it lands on UnqueuedProfileSynchronization. The kind 0 the relays would answer with is indexed through the read-only key pair: ProfileLoaded. And with the real NotaryViewModel watching the same rows for longer than its key package delay, nothing was signed -- the only unsigned row for the pubkey is the placeholder, still at genesis; nothing is queued for a signature; no key package bundle; no broadcast request; no node. The contrast that makes those assertions worth having: the same harness as a signing identity does sign -- the notary makes a key package bundle within its delay. Without it, "nothing was signed" could be true of a harness in which nothing can be signed. Then the upgrade: the nsec of the key held read-only signs in over it through the same view model, under the same id, leaving one credential that is now a secret, one listed identity, and one account -- the second sign-in planted nothing. The full jvm suites pass: 988 in the app, 159 in the library. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@315e93315a3734ae35a319ec4892b30bde318eba --- .../identity/NpubPreviewRoundTripJvmTest.kt | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt new file mode 100644 index 00000000..0a389ce0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NpubPreviewRoundTripJvmTest.kt @@ -0,0 +1,262 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.model.NotaryViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.state.NavigationUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The preview, end to end, and then the upgrade -- Phase 7 of docs/npub-sign-in.md. + * + * Unlike `NsecRestoreRoundTripJvmTest`, the repository here is real: an in-memory + * database under the same DAOs the app runs on, because the assertion this exists for + * is about what is *not* in it. Sign in with an npub the way the sign-in screen does, + * list identities the way startup does, activate the read-only one, hand + * NavigationViewModel the device: it lands on UnqueuedProfileSynchronization. Index the + * kind 0 the relays would have answered with, through the read-only key pair: it lands + * on ProfileLoaded. And with a real NotaryViewModel running against the same database + * for longer than its key package delay: **nothing was signed** -- the only unsigned row + * for the pubkey is the placeholder, still at genesis; no key package bundle; no + * broadcast request; no node. + * + * Then the nsec of the same key is written the way the sign-in screen writes it: the + * same id, one entry that is now a secret, and one account -- the second sign-in + * planted nothing. + */ +class NpubPreviewRoundTripJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + private lateinit var globalPrefs: GlobalPrefs + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val chatRepository = DatabaseChatRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val log = Logger.withTag("NpubPreviewRoundTripJvmTest") + private val privateKey = PrivateKey(Lightning.randomBytes(32)) + private val publicKey = privateKey.nostrPublicKeyHex() + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-preview-store").toFile() + appDir = Files.createTempDirectory("mantra-preview-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + globalPrefs = GlobalPrefs( + PreferenceDataStoreFactory.createWithPath { + computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") + } + ) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private fun signInViewModel() = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, key, isTorEnabled = false, customElectrumServer = null) }, + writeNostrPublicKey = { pubkey -> IdentityWriter.writeNostrPublicKey(log, phoenixGlobal, globalPrefs, pubkey, isTorEnabled = false, customElectrumServer = null) }, + writeRecoveryPhrase = { _, _, onError -> onError(press.mantra.compose.ui.view.model.WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + private fun listed() = StoredIdentity.merge( + wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"), + credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("credentials unreadable"), + ) + + /** The kind 0 the relays would answer the sign-in sync with. */ + private fun kind0() = NostrEvent( + id = "e".repeat(64), + pubKey = publicKey, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = """{"name":"Reader"}""", + sig = "0".repeat(128), + createdAt = at, + ) + + @Test + fun `an npub signed in, listed, activated, routed and watched by the notary, with nothing signed`() = runBlocking { + // 1. The sign-in screen's commit, minus the screen: the credential, then the account. + val outcome = signInViewModel().commit(SignInCredential.NostrPublicKey(publicKey)) + val signedIn = assertIs(outcome) + + // 2. What startup lists. + val stored = assertIs(listed()[signedIn.id], "the public key is listed under the id the writer returned") + assertEquals(publicKey, stored.nostrPublicKey) + + // 3. What the startup screen's NostrPublic branch does: an identity with nothing behind it. + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val identity = Identity.readOnly( + id = stored.id, + nostrPublicKey = stored.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + ) + assertFalse(identity.canSign) + assertNull(identity.nostrPrivateKey) + + val activeIdentity = MutableStateFlow(null) + val navigation = NavigationViewModel( + activeIdentityStateFlow = activeIdentity, + initialNavigationUIState = NavigationUIState.Loading("Introducing... Torch"), + nostrRepository = nostrRepository, + scope = scope, + ) + // The real notary, watching the same rows the app's would. + NotaryViewModel( + activeIdentityStateFlow = activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + + activeIdentity.value = identity + + // 4. The machine takes it from the placeholder: fetch, do not create. + val first = withTimeout(15_000) { + navigation.navigationUIState.first { it !is NavigationUIState.Loading } + } + assertIs(first) + + // 5. The relays answered; the kind 0 is indexed through the read-only pair: home. + db.nostrDao().storeNostrEvent( + nostrEvent = kind0(), + relayURL = "wss://relay.example", + synchronizationRelayURLs = listOf("wss://relay.example"), + level = 0, + activeKeyPair = identity.toKeyPair(), + ) + val second = withTimeout(15_000) { + navigation.navigationUIState.first { it is NavigationUIState.ProfileLoaded } + } + assertEquals(NavigationUIState.ProfileLoaded(publicKey = publicKey), second) + + // 6. Longer than the notary's key package delay, and then: nothing was signed. + delay(4_500) + val accounts = nostrRepository.getLocalAccounts().filter { it.unsignedNostrEvent?.pubKey == publicKey } + assertEquals(1, accounts.size, "one account for the pubkey") + assertEquals(GENESIS_AT, accounts.single().unsignedNostrEvent!!.signedAt, "the placeholder is still the placeholder") + assertEquals(emptyList(), nostrRepository.observeUnsignedNostrEvents(publicKey).first(), "nothing is queued for a signature") + assertEquals(emptyList(), db.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey), "no key package bundle was made") + assertNull(db.broadcastNostrEventRequestDao().observeBroadcastNostrEventRequestsByStatus("pending").first(), "nothing is waiting for a relay") + assertNull(identity.business) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a public key") + } + + /** + * The contrast that makes the assertions above worth having: the same harness, as an + * identity that *can* sign, does sign -- the notary makes a key package bundle for it + * within its delay. Without this, "nothing was signed" could be true of a harness in + * which nothing can be signed. + */ + @Test + fun `the same harness as a signing identity does sign`() = runBlocking { + val signedIn = assertIs( + signInViewModel().commit(SignInCredential.NostrSecret(privateKey, publicKey)) + ) + val stored = assertIs(listed()[signedIn.id]) + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val identity = Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + val activeIdentity = MutableStateFlow(identity) + NotaryViewModel( + activeIdentityStateFlow = activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + + withTimeout(20_000) { + marmotRepository.observeActiveMarmotKeyPackageBundle(publicKey).first { it != null } + } + assertTrue(db.marmotKeyPackageBundleDao().getAllMarmotKeyPackageBundles(publicKey).isNotEmpty()) + } + + @Test + fun `the nsec of a key held read-only signs in over it, under the same id, planting nothing new`() = runBlocking { + val readOnly = assertIs( + signInViewModel().commit(SignInCredential.NostrPublicKey(publicKey)) + ) + assertIs(listed()[readOnly.id]) + + val upgraded = assertIs( + signInViewModel().commit(SignInCredential.NostrSecret(privateKey, publicKey)) + ) + + assertEquals(readOnly.id, upgraded.id, "the identity keeps its id, and with it its preferences and metadata") + val credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + assertEquals(mapOf(publicKey to NostrCredential.Secret(privateKey)), credentials, "one entry, now a secret") + val identities = listed() + assertEquals(1, identities.size) + assertIs(identities[upgraded.id]) + assertEquals(1, nostrRepository.getLocalAccounts().count { it.unsignedNostrEvent?.pubKey == publicKey }, "the second sign-in planted nothing") + } +} From 042c5db5f447f5dfbc17c36c696adf46ef71d000 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:52:53 +0200 Subject: [PATCH 36/71] docs: record what the npub sign-in plan built, and the twelve places it chose differently Phase 8 of docs/npub-sign-in.md is the rollout, which is process rather than code; what is left to write down is what the seven phases before it turned out to be. The header moves from "Not built" to "Built", with the table the other phased plans keep: what the plan said against what the implementation did, twelve rows, each a decision worth reading before touching the code it describes -- the startup branch one phase early because the sealed type asked for it, the migration's three outcomes, the DAO guard that was belt-and-braces on paper and load-bearing for two phases, a null identity answering "can sign" with true because nobody is not read-only, and a round trip run against a real database with a contrast case so that "nothing was signed" is known to be a claim the harness can refute. One finding that belongs to no phase is recorded with it: a compose test looking for a floating action button's label has to search the unmerged tree, or its "does not exist" is vacuously true. And the one rollout fact that is not process: the library commit is on claude/nostr-credentials at 01962f3, bumped in by Phase 2, and like the nsec plan's before it has to be pushed and tagged before any of this leaves the machine. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@f866b9d17bfd07acd2c0c20818a6560d6aeb58b4 --- docs/README.md | 4 ++-- docs/npub-sign-in.md | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/README.md b/docs/README.md index c757cb9c..74c605cd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,7 @@ The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the code open, and read its table of where the build chose differently first. -The npub sign-in note is a phased plan that has not been built, and reads as that +The npub sign-in note is a phased plan that has been built, and reads as that plan's out-of-scope note answered: it takes the `Identity` type and the sign-in machine as given and asks what an identity with no secret is for, before it asks -how to build one. +how to build one; read its table of where the build chose differently first. diff --git a/docs/npub-sign-in.md b/docs/npub-sign-in.md index 6e8bc042..62f00042 100644 --- a/docs/npub-sign-in.md +++ b/docs/npub-sign-in.md @@ -9,12 +9,37 @@ type, the two key stores, the sign-in screen and the not-found exit from there, most of what follows is that plan's out-of-scope note taken at its word — "a read-only mode is a product, not a branch" — and asked what the product would be. -**Not built.** Phases 1–8 below, one commit each, in the order given. Phase 1 is a -type change and ships alone. Phase 2 is the only phase that touches the library, -and it rides the push and tag the nsec plan's library commit still owes. Phases 3 through 5 ship -together, for the reason in [Phase 8](#phase-8--rollout): a build that can make a -read-only identity but still offers it "New chat" is the failure this plan exists -to avoid. +**Built**, phases 1–7, one commit each, in the order given; Phase 8 is the rollout +and is process rather than code. The phases are kept as written because they are +the reasoning, and the code reads better against the argument it came from than +against a summary of itself. Where the implementation chose differently the table +below says so: + +| what the plan said | what it turned out to be | +|---|---| +| the startup screen's third branch in Phase 3 | in Phase 2: `StoredIdentity` is sealed, and the compiler asked for the branch the moment `NostrPublic` existed. The right code either way, one phase early | +| `KeyRecoveryScreen`'s `NostrPublic` arm "unreachable, and says so" | for the option, `Unit` with the comment; for the sentence above it, grouped with `NostrSecret` — so that if the screen is ever reached it does not promise coins | +| "the old reader kept as `LegacyNostrKeysFile` for exactly one caller" | `LegacyNostrKeysFile` is the read half of what was `NostrKeyManager`, and `EncryptedNostrKeys` stays beside it, because the migration's test has to *produce* a v1 file and nothing else can | +| the migration, unspecified in its outcomes | `MigrationResult` — `Migrated(count)`, `NotNeeded`, `Failed(failure)` — and `listIdentities` maps a failure onto the same `ListWalletState.Error` an unreadable credentials file gets, per kind | +| the wire shape as "a `@Serializable sealed class`" | two types: `NostrCredential`, whose `Secret` carries a `PrivateKey` and cannot be built with a key that is not one, and a private serializable mirror inside the encrypted file whose `Secret` carries hex | +| the DAO guard as "belt and braces" | load-bearing between Phase 3 and Phase 5, when the room list's inbox sync still ran for a read-only identity; and verified both ways — the test fails with the guard removed | +| `ProvideSigningCapability` from the active identity | a null identity — startup, landing, sign-in — answers *true*: it is nobody, not read-only, and those screens have nothing to hide | +| `ForgetIdentity` "dispatches the first step on its kind" | no dispatch: since the credentials file the first step is one call for both credential kinds, and `forgetNostrCredential` itself answers `NotACredential` for a mnemonic | +| sign out and the not-found exit, shape unspecified | one `SignOutViewModel` owning the confirmation and the in-flight state, one `SignOutOfReadOnlyDialog` differing only in its title, and a `SignOutDependencies` bundle so each screen takes one nullable parameter rather than four lambdas that throw | +| `signInToProfile` "made idempotent by pubkey" | done, and tested by signing in twice and counting one; the sign-in view model's three writers now go through one shared write-and-map | +| the round trip "with the network faked at the repository" | with the *database* real: an in-memory Room under the app's own DAOs and a real `NotaryViewModel`, because "nothing was signed" is about what is not in the tables — plus a contrast case the plan did not ask for, the same harness as a signing identity producing a key package, so the assertions are known to bite | +| `use_a_different_key` as a new string | it existed: the sign-in screen's own "use a different key" button. One string, two screens, one meaning | + +One thing found on the way that is in no phase: a compose test that looks for a +floating action button's label by text has to search the unmerged tree, because +`ExtendedFloatingActionButton` merges its label into the button's semantics — and +on the merged tree an `assertDoesNotExist` for a hidden control is vacuously true. +`ReadOnlyEntrancesJvmTest` uses the unmerged tree for every lookup for that reason. + +The library commit is on `claude/nostr-credentials` in the submodule, at `01962f3`, +bumped into the app by the Phase 2 commit; like the nsec plan's `59c11ed` before +it, it has to be pushed with this branch and tagged for JitPack consumers before any +of this leaves the machine. Phase 8 below says why the two go out on one tag. ## The constraint From 89e364c1bd2a7f2218f1d6ad02828cec98df6de9 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 22:15:19 +0200 Subject: [PATCH 37/71] chore(library): base the credentials branch on the library's master The Phase 2 library commit was made on a branch cut from 59c11ed -- the nsec key-store commit, which is what the app's curated branch pins -- but that commit is not the library's default branch's tip: master is at e51e3ae, the merge of PR #1 that brought 59c11ed in. The two trees are identical, so the rebase is content-free; what changes is that claude/nostr-credentials is now one commit ahead of master rather than a sibling of its tip, and the app's pointer follows it to 84cc44c. The plan said the nsec library commit "sits on a remote branch called detached", which was true of where it was found and false of where it is: it reached master through the PR. What is still true, and still the rollout's one hard step, is that it is untagged -- the library has no tags at all -- and JitPack consumers resolve by tag. The two sentences that said otherwise are corrected. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@00c36ec509d2e9de8fbdb365636881638af8855c --- docs/npub-sign-in.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/npub-sign-in.md b/docs/npub-sign-in.md index 62f00042..91eda7e9 100644 --- a/docs/npub-sign-in.md +++ b/docs/npub-sign-in.md @@ -36,10 +36,12 @@ floating action button's label by text has to search the unmerged tree, because on the merged tree an `assertDoesNotExist` for a hidden control is vacuously true. `ReadOnlyEntrancesJvmTest` uses the unmerged tree for every lookup for that reason. -The library commit is on `claude/nostr-credentials` in the submodule, at `01962f3`, -bumped into the app by the Phase 2 commit; like the nsec plan's `59c11ed` before -it, it has to be pushed with this branch and tagged for JitPack consumers before any -of this leaves the machine. Phase 8 below says why the two go out on one tag. +The library commit is on `claude/nostr-credentials` in the submodule, at `84cc44c`, +one commit ahead of the library's `master` and bumped into the app by the Phase 2 +commit; it has to be pushed with this branch and tagged for JitPack consumers +before any of this leaves the machine. The nsec plan's `59c11ed` reached `master` +through the library's PR #1 but was never tagged; Phase 8 below says why the two go +out on one tag. ## The constraint @@ -834,11 +836,12 @@ same id, same metadata. ## Phase 8 — rollout **Library first, and on the tag the nsec work still owes.** Phase 2 is a commit -on the submodule, reaching the app as a pointer bump in the Phase 3 commit. The -library commit that introduced `nostr-keys.dat` is untagged and sits on a remote -branch called `detached`; the nsec plan says it has to be pushed and tagged before -any of that work leaves the machine, and Phase 2 goes out on the same tag, so that -no JitPack consumer ever sees the v1 file without the reader that migrates it. +on the submodule, reaching the app as a pointer bump in the Phase 2 commit. The +library commit that introduced `nostr-keys.dat` is on the library's `master` — it +arrived through PR #1 — but is untagged, and the library has no tags at all; the +nsec plan says it has to be tagged before any of that work leaves the machine, and +Phase 2 goes out on the same tag, so that no JitPack consumer ever sees the v1 file +without the reader that migrates it. **Phase 1 ships alone.** A type change with two `require`s and one exhaustive `when`; if the release after it behaves differently, the cause is in one diff. From 1c59ed34d7192e2619dc4504629183c6cd903605 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 22:22:36 +0200 Subject: [PATCH 38/71] chore(library): track the submodule's default branch .gitmodules names master as the branch lightning-kmp-app tracks, so that `git submodule update --remote` follows the library's default branch rather than whatever this git's fallback happens to be. The pinned commit is unchanged: the app still builds against 84cc44c on claude/nostr-credentials, one commit ahead of master, until that commit is merged and the pointer moved to master's tip -- which is what tracking master says should happen. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@3116eb8a8c42b94fa1185863b5afa1dcf2018c82 --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index 3c2343b8..0f775bbb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "lightning-kmp-app"] path = lightning-kmp-app url = https://github.com/kngako/lightning-kmp-app.git + branch = master From 0fa807a6fbd85ca7915043cb127c8cb1298e05f5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:06:46 +0200 Subject: [PATCH 39/71] docs: record what the pull built, and the seven places it chose differently Phases 0 to 5 of docs/curated-to-mantra.md are landed on this branch: thirty commits pulled from curated/curated, each carrying a `Pulled-From` trailer naming the commit it came from, on top of Phase 0's two native ones. The plan is kept as written and its status line now says what happened, with the table the house keeps for a plan that has been built -- what it said against what it turned out to be -- and the README's sentence about it follows. **Seven differences, and none of them are corrections to the plan's decisions.** The three decisions -- drop the brand line but keep its reasoning, keep Mantra's sections by dropping 808a3459, take the curated lists -- all held, and the measured predictions about them (the screen's order, the tests, the audit) came true to the number. What changed was mechanics: the phased replay is a cherry-pick per commit rather than a rebase of each cut, because a recreated merge cannot reach a parent that was replayed in an earlier phase; the library pin follows the commit rather than staying put, because the compiler said the nsec line does not build against 84cc44c; the join files are taken from upstream's own merges rather than unioned, for three reasons that each took a failed attempt to learn; and the exactness residual is seventeen files rather than sixteen plus a logo, because Mantra's own side had moved too. Each is written in the table with the reasoning beside it, so the next pull starts from what happened. **The numbers are per phase, so a regression later can be placed.** jvmTest 736, 864, 905, 1,007, 1,039 and testDebugUnitTest 403, 486, 495, 530, 530 across Phases 0 to 5; both compilers clean and every m3 audit budget met at each; the final jvmTest executed rather than restored from the build cache, 1,039 tests in 46 seconds of test time, 0 failures. The plan's own dry run predicted 1,036; the three extra are 39fb64b6's pin of the library's nostrPublicKey() against NIP-06. **What is not done is named rather than implied.** Phase 6's two reverse-pulls -- 39fb64b6, since the fork still carries the app-side WalletManagerExtension.kt it made redundant, and Phase 0's Torch retirement -- land in the other repository and are not this branch's to make. Its last item is a decision about what the fork becomes, and the plan's recommendation stands: converge the source package so the next pull is a plain cherry-pick. And the upstream has already moved on -- ten commits for several profiles on one device, one of them the fork's first schema migration -- which are the next pull, kept separate because a migration landing on Mantra's database deserves a decision of its own. Co-Authored-By: Claude Opus 5 --- docs/README.md | 7 ++++--- docs/curated-to-mantra.md | 30 +++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/README.md b/docs/README.md index 74c605cd..6e93b973 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,9 +38,10 @@ once it has a key, and what it can say about a group that does not yet. The Material Design note is a phased plan that has not been built, and is the only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists. -The curated-to-mantra note is a phased plan that has not been built, though its dry -run has: it is about the repository rather than the app, and reads alone, except that -its first decision leans on the derivation note's one rule. +The curated-to-mantra note is a phased plan that has been built, save for its last +phase, which is a decision: it is about the repository rather than the app, and reads +alone, except that its first decision leans on the derivation note's one rule. The +nsec and npub sign-in notes arrived with it and record what they built. The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md index 6865de7c..21629146 100644 --- a/docs/curated-to-mantra.md +++ b/docs/curated-to-mantra.md @@ -14,11 +14,31 @@ rule this whole exercise is built around — that two strings in this tree are h and must never be renamed — and the rebrands upstream were careful about it, which is the only reason a mechanical pull is possible at all. -**Not built.** Phase 1's dry run *has* been done, once, on 2026-09-13, on a scratch -worktree that was thrown away; every number below is measured on that run, not -estimated. Written against `origin/mantra` at `ba26c0b1` (with `f4434ab1` and -`39fb64b6` on the update branch) and `curated/curated` at `86cb876b`. Curated moves -daily: the numbers will drift, the method will not. +**Built, Phases 0–5, on 2026-09-13**, on the branch that also carries `f4434ab1` and +`39fb64b6`: thirty commits pulled from the fork, each with a `Pulled-From` trailer, on +top of Phase 0's two native ones. Phase 6 is not done: its two reverse-pulls land in the +other repository, and its last item is a decision. The plan below is kept as written, +because the code reads better against the argument it came from than against a summary +of itself; where the execution chose differently the [Phase 1 record](#phase-1--the-dry-run) +says so, and it did so in seven places worth reading: + +| what the plan said | what it turned out to be | +|---|---| +| each phase is a `rebase --rebase-merges --onto` of a cut | a cherry-pick per commit, in topological order. A recreated merge whose other parent was replayed in an earlier phase would be pointed at the *pre*-replay commit; so the four merges land as nothing and their resolutions are folded in where the conflicts surface. The driver is [`docs/scripts/curated-replay.py`](./scripts/curated-replay.py) | +| the pin stays at `84cc44c` throughout | it follows the commit. The nsec line writes through `NostrKeyManager`, which `84cc44c` renamed away; the compiler produced eight unresolved references at the Phase 3 cut. `366b0177` moves the pin to `59c11ed` and `5efeae76` brings it to `84cc44c`, so every commit builds against the library it names. A populated submodule made git fast-forward the gitlink past the rule once, silently; the rule is now unconditional | +| take the rebrand residue *or* leave it for Phase 2 | Phase 0 took it, natively worded, so the three rewritten brand commits were dropped outright rather than squashed | +| Phase 2, 3 and 5 replay with no conflicts | Phase 3 had two, both foreseen in kind: the import block `39fb64b6` had already moved (theirs), and one word of a class comment the dropped rebrand had capitalised (theirs). Phase 5 had none | +| Phase 4's three conflicts, resolved by hand | the same three, but the join files are taken from upstream's own merges (`c8de3a1f`, `fedbe724`) rather than unioned: a union duplicates shared lines, ignores the other side's deletions, and — the one that hid longest — orders the joined blocks its own way so later commits miss their base. `fcc19f95`'s deletion of two strings survived two attempts before that was understood | +| the exactness diff is the logo plus `808a3459`'s three files | plus the fourteen files Mantra changed on its own side: Phase 0's prose at the protected names, `39fb64b6`'s four, and this document with its two scripts. Seventeen files, every one accounted for, at every cut | +| 1,036 jvm tests at the end | 1,039 — the three from `39fb64b6` — and per phase 736, 864, 905, 1,007, 1,039; `testDebugUnitTest` 403, 486, 495, 530, 530; every phase clean on both compilers with every audit budget met | + +The upstream moved before this landed: `curated/curated` went from `86cb876b` to +`29027f2b`, ten commits for several profiles on one device, one of them the fork's +first schema migration (version 20). They are the next pull, and the schema change is +why they get their own decision rather than a ride on this one. + +Written against `origin/mantra` at `ba26c0b1` and `curated/curated` at `86cb876b`; +executed on 2026-09-13. ## The shape of the fork From 776455ecb0ae4e5de5e6285c82d52f282113857d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 14:45:15 +0200 Subject: [PATCH 40/71] refactor(groups): take out the group's nostr identity and the curated lists, and keep broadcast unreachable Lines B, D and H of docs/curated-to-mantra.md, pulled from the Curated fork this morning, go again this afternoon: the group's nostr profile (kind 0), its four relay lists (NIP-65, NIP-17, NIP-50, NIP-51), its posts (kind 1), the curated schemas it publishes (31889), the suggestions it reads (31888) and the entries it accepts (31890). Eighty-two files, twelve screens, the `nostr/curated/` package, the readings (`GroupNostrProfile`, `GroupRelayList`, `GroupPost`, `GroupCuratedSchema`, `GroupCuratedEntry`, `CuratedSuggestion`), the `applyInnerEvent` arms for all eight kinds, the `ChatRepository` and `NostrRepository` reads that fed them, 226 strings, and every test that came with them. A translation collective has no list to curate and no reason to describe itself under a key no member controls, and five rows saying so on every group's screen were five rows about somebody else's product. **The pasted-event proposal goes with them, because it was theirs.** `GroupEventProposal.ACCEPTED_KINDS` was exactly kinds 0, 1, the four relay-list kinds and 31889 -- "the kinds the group's screen has a place for", in its own words -- so with the five rows gone it would have refused every paste. Keeping it as a generic "sign any event" was considered and rejected: the screen's whole argument was that a member goes to the row to see the event landed, and there is no row. The `ProposedEvent` summaries for the same kinds go too; the one test of its pre-existing fallback ("Event of kind N") is kept, as the only line of `ProposedEventTest` that was about code this repository had before the pull. **Broadcast stays, and nothing opens it.** `BroadcastGroupSignedEventScreen`, its route, view model, state and `BroadcastButton` are kept, on the decision that a way to send a group-signed event to relays is worth having against the day something wires a button in -- the button's only call sites were the five removed screens. Two things had to change for it to compile against a tree with no relay lists: `defaultRelaysFor(kind)` is now the app's own publish set for every kind, since the General list it preferred, the Blocked list it filtered by and the schema relays it widened to no longer exist; and `relayUrlOrNull` moves into the view model's companion from the deleted `GroupRelaySet`, unchanged. The hint on the screen says "the relays this app publishes to" rather than "where the group has said it lives". Its two tests are rewritten around the new seed: the screen test answers for the first two seeded relays and counts the rest as asked, and empties the list by hand to see the empty state, since the seed always has something in it. Deleting broadcast outright -- the tidier tree -- was the recommendation and was declined. **Every pre-existing file is back at its pre-pull content plus the kept lines' hunks, and nothing else.** Eleven files -- `ChatMessage.kt`, `NostrEventDao.kt`, `NostrEvent.kt`, `LocalChatRoom.kt`, `Member.kt`, `ProposedEvent.kt`, `ChatTranscript.kt`, `ProfileAvatar.kt`, the group screen's view model and state, and `m3-title-case.py` -- were touched by no kept commit and are restored from ba0830a3 byte for byte, so `inComparableGroups` is a private helper of the group screen again rather than a shared extension one deleted screen needed, and `ProfileAvatar` has one overload again. The rest were restored and had the kept hunks re-applied: the back button's three on `ChatRoomDetailScreen`, broadcast's `groupSignedEvent` read on the two chat repositories, and on the two nostr repositories the sign-in reads, by reverse-applying the queue commit's hunk. The check is `git diff ba0830a3 -- `, which shows only those. Reverting the eight commits was rejected because the back button and the read-only identity work landed on top of them and would have conflicted in every one of the shared files; editing the current files by hand was rejected because it leaves residue that a diff against the base cannot distinguish from a decision. **The strings that went are exactly the ones nothing references any more and that the pull added.** Six strings were unreferenced before the pull and stay; the header sentence and one capitalised "Mantra" that the queue commit's join carried are prose, not feature, and stay too. The seven section comments that described removed blocks go; the broadcast block's stays. **The plan's third decision said "hide the two rows behind a constant".** That covered the curated rows and not the profile, relays and posts beside them, and the call was to take the whole identity block out. The record of what went and what stayed is the paragraph after the built table in docs/curated-to-mantra.md, with the seam it leaves for the next pull: an upstream commit that touches the identity block conflicts at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and is dropped. docs/README.md says the same in a sentence. The nsec and npub notes still name `EditGroupCuratedSchemaViewModel`; they are records of what was built upstream and are left as written. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:compileKotlinJvm, :composeApp:jvmTest (826 tests, from 1,039), :composeApp:testDebugUnitTest (413, from 530) and :composeApp:m3Audit, every budget met, 12 adaptive uses at the floor. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 235 +-- .../compose/database/dao/NostrEventDao.kt | 11 - .../compose/database/model/ChatMessage.kt | 283 +-- .../compose/database/model/NostrEvent.kt | 11 - .../model/intermdiate/LocalChatRoom.kt | 3 +- .../repository/DatabaseChatRepository.kt | 79 - .../repository/DatabaseNostrRepository.kt | 6 - .../press/mantra/compose/extensions/Member.kt | 17 - .../mantra/compose/nostr/CuratedSuggestion.kt | 115 -- .../mantra/compose/nostr/GroupCuratedEntry.kt | 134 -- .../compose/nostr/GroupCuratedSchema.kt | 206 --- .../compose/nostr/GroupEventProposal.kt | 188 -- .../mantra/compose/nostr/GroupNostrProfile.kt | 196 --- .../press/mantra/compose/nostr/GroupPost.kt | 121 -- .../mantra/compose/nostr/GroupRelayList.kt | 279 --- .../nostr/curated/CuratedEntryEvent.kt | 601 ------- .../compose/nostr/curated/CuratedSchema.kt | 475 ----- .../nostr/curated/CuratedSchemaEvent.kt | 206 --- .../compose/repository/ChatRepository.kt | 75 - .../compose/repository/NostrRepository.kt | 14 - .../mantra/compose/text/ProposedEvent.kt | 70 - .../AcceptCuratedSuggestionScreen.kt | 839 --------- .../ui/composable/AddGroupPostScreen.kt | 337 ---- .../BroadcastGroupSignedEventScreen.kt | 36 +- .../ui/composable/ChatRoomDetailScreen.kt | 324 +--- .../composable/CuratedSuggestionListScreen.kt | 675 -------- .../EditGroupCuratedSchemaScreen.kt | 1525 ----------------- .../composable/EditGroupNostrProfileScreen.kt | 427 ----- .../ui/composable/EditGroupRelaysScreen.kt | 581 ------- .../composable/GroupCuratedEntriesScreen.kt | 365 ---- .../composable/GroupCuratedSchemasScreen.kt | 442 ----- .../ui/composable/GroupNostrProfileScreen.kt | 353 ---- .../compose/ui/composable/GroupPostsScreen.kt | 352 ---- .../ui/composable/GroupRelaysScreen.kt | 327 ---- .../ui/composable/ProposeGroupEventScreen.kt | 469 ----- .../ui/composable/navigation/MantraNavHost.kt | 264 --- .../routes/AcceptCuratedSuggestionRoute.kt | 22 - .../navigation/routes/AddGroupPostRoute.kt | 10 - .../routes/CuratedSuggestionListRoute.kt | 19 - .../routes/EditGroupCuratedSchemaRoute.kt | 20 - .../routes/EditGroupNostrProfileRoute.kt | 10 - .../navigation/routes/EditGroupRelaysRoute.kt | 10 - .../routes/GroupCuratedEntriesRoute.kt | 16 - .../routes/GroupCuratedSchemasRoute.kt | 16 - .../routes/GroupNostrProfileRoute.kt | 19 - .../navigation/routes/GroupPostsRoute.kt | 15 - .../navigation/routes/GroupRelaysRoute.kt | 15 - .../routes/ProposeGroupEventRoute.kt | 11 - .../widgets/buttons/BroadcastButton.kt | 7 +- .../composable/widgets/chat/ChatTranscript.kt | 19 - .../widgets/profile/ProfileAvatar.kt | 35 +- .../model/AcceptCuratedSuggestionViewModel.kt | 324 ---- .../ui/view/model/AddGroupPostViewModel.kt | 154 -- .../BroadcastGroupSignedEventViewModel.kt | 129 +- .../ui/view/model/ChatRoomDetailViewModel.kt | 15 +- .../model/CuratedSuggestionListViewModel.kt | 257 --- .../model/EditGroupCuratedSchemaViewModel.kt | 544 ------ .../model/EditGroupNostrProfileViewModel.kt | 161 -- .../ui/view/model/EditGroupRelaysViewModel.kt | 299 ---- .../model/GroupCuratedEntriesViewModel.kt | 65 - .../model/GroupCuratedSchemasViewModel.kt | 66 - .../view/model/GroupNostrProfileViewModel.kt | 71 - .../ui/view/model/GroupPostsViewModel.kt | 64 - .../ui/view/model/GroupRelaysViewModel.kt | 62 - .../view/model/ProposeGroupEventViewModel.kt | 158 -- .../state/AcceptCuratedSuggestionUIState.kt | 34 - .../ui/view/state/AddGroupPostUIState.kt | 33 - .../state/BroadcastGroupSignedEventUIState.kt | 15 - .../ui/view/state/ChatRoomDetailUIState.kt | 77 - .../state/CuratedSuggestionListUIState.kt | 40 - .../state/EditGroupCuratedSchemaUIState.kt | 42 - .../state/EditGroupNostrProfileUIState.kt | 34 - .../ui/view/state/EditGroupRelaysUIState.kt | 33 - .../view/state/GroupCuratedEntriesUIState.kt | 26 - .../view/state/GroupCuratedSchemasUIState.kt | 31 - .../ui/view/state/GroupNostrProfileUIState.kt | 35 - .../ui/view/state/GroupPostsUIState.kt | 40 - .../ui/view/state/GroupRelaysUIState.kt | 31 - .../ui/view/state/ProposeGroupEventUIState.kt | 32 - .../compose/nostr/CuratedSuggestionTest.kt | 191 --- .../compose/nostr/GroupCuratedEntryTest.kt | 317 ---- .../compose/nostr/GroupCuratedSchemaTest.kt | 317 ---- .../compose/nostr/GroupEventProposalTest.kt | 338 ---- .../compose/nostr/GroupNostrProfileTest.kt | 438 ----- .../mantra/compose/nostr/GroupPostTest.kt | 307 ---- .../compose/nostr/GroupRelayListTest.kt | 398 ----- .../nostr/curated/CuratedEntryEventTest.kt | 546 ------ .../nostr/curated/CuratedSchemaEventTest.kt | 450 ----- .../mantra/compose/text/ProposedEventTest.kt | 110 +- .../AcceptCuratedSuggestionScreenJvmTest.kt | 216 --- .../BroadcastGroupSignedEventScreenJvmTest.kt | 102 +- .../CuratedSuggestionListScreenJvmTest.kt | 333 ---- .../EditGroupCuratedSchemaScreenJvmTest.kt | 267 --- .../GroupCuratedEntriesScreenJvmTest.kt | 133 -- .../GroupCuratedSchemasScreenJvmTest.kt | 231 --- .../GroupNostrProfileScreenJvmTest.kt | 153 -- .../ui/composable/GroupPostsScreenJvmTest.kt | 167 -- .../ui/composable/GroupRelaysScreenJvmTest.kt | 139 -- .../ui/composable/GroupSignedWorkFixtures.kt | 212 --- .../composable/GroupSignedWorkRowsJvmTest.kt | 282 --- .../ProposeGroupEventScreenJvmTest.kt | 117 -- ...AcceptCuratedSuggestionViewModelJvmTest.kt | 412 ----- ...oadcastGroupSignedEventViewModelJvmTest.kt | 179 +- .../CuratedSuggestionListViewModelJvmTest.kt | 335 ---- .../EditGroupCuratedSchemaViewModelJvmTest.kt | 382 ----- .../model/EditGroupRelaysViewModelJvmTest.kt | 224 --- docs/README.md | 6 +- docs/curated-to-mantra.md | 25 + docs/scripts/m3-title-case.py | 2 - 109 files changed, 176 insertions(+), 20910 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt delete mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt delete mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index de74e248..1cee3dcc 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -398,250 +398,17 @@ Check the combined result and confirm it The group has its key — finish setting it up Open - Nostr profile - This group hasn't said anything about itself on Nostr yet. - - Not described yet - Edit Nostr profile - Propose Nostr profile - What the group says about itself on Nostr - The group signs this, so it takes a quorum. An empty field unsays what the group had said. - This group has no shared key, so it cannot sign a profile. Run a shared key ceremony first. Signed by the group on %1$s - No name on this profile - About - Picture url - Website - Nostr address - Lightning address - eg. Translation collective - eg. A group translating hard books into Sesotho - eg. https://example.com/group.png - eg. https://example.com - eg. group@example.com - eg. group@getalby.com - Couldn't ask the group to sign this profile. Relays - Edit relays - Not set yet - No relays in this list - - %1$s of %2$s lists agreed - Where the group lives on Nostr - The group signs each list, so it takes a quorum. Only the lists you change are proposed. - This group has no shared key, so it cannot sign a relay list. Run a shared key ceremony first. Relay url eg. wss://relay.example.com Add relay That isn't a relay address. Relays start with wss:// and cannot be on this device. That relay is already in this list. - Propose relays - Nothing has changed, so there is nothing to propose. - Couldn't ask the group to sign these relays. - Read - Write - General - Messages - Search - Blocked - Where the group publishes, and where a reader should look for it. - Where a message addressed to the group should be sent. - The relays a search of the group's work runs against. - Relays the group will not talk to. - A group signs and cannot decrypt, so every list here is public — including the blocked one, which most clients keep private. - Signed %1$s - Posts - Add post - Propose post - This group hasn't posted anything yet. - - No posts yet - 1 post - %1$s posts - What should the group say? - The group signs a post, so it takes a quorum. It goes out in the group's name, not yours. - This group has no shared key, so it cannot sign a post. Run a shared key ceremony first. - A post needs something to say. - Couldn't ask the group to sign this post. - Posted %1$s - A reply - Curated schemas - This group hasn't published a curated schema yet. A schema defines a list anyone can suggest entries to and only the group can curate. - - No schemas yet - 1 schema - %1$s schemas - Add schema - Edit schema - Propose schema - Fields: %1$s - Public - Closed - Private - Anyone may suggest an entry, and anyone may read the list. - Only the group and the suggesters below may suggest. Anyone may read the list. - Only the group and the suggesters below may suggest, and clients show the list to them alone. Relays are open, so this is a convention rather than a secret. - What an entry in this list may contain, and who may suggest one - The group signs a curated schema, so it takes a quorum. Suggestions reply to it, and only the group can accept them into the list. - This group has no shared key, so it cannot sign a curated schema. Run a shared key ceremony first. - Couldn't ask the group to sign this schema. - Identifier - eg. bitcoin.mov - The identifier names this list in every reply to it, so it can't change. Add a schema to start another list. - Title - eg. bitcoin.mov suggestion - Description - eg. Fields for a bitcoin.mov entry: a film, with at least one link to watch it - Visibility - Domain - eg. example.com - A domain replaces the name wherever the list is shown. It's a claim rather than proof, so only set one the group controls. - Fields - Add field - Required - Optional - Derived - At least one of - Add rule - Pick two or more fields, at least one of which an entry must have. A rule with fewer than two is dropped. - Suggesters - Pubkeys besides the group that may suggest to this list. - npub or hex pubkey - Add suggester - That isn't a pubkey. Paste an npub or 64 hex characters. - That pubkey is already a suggester. - Where suggestions and entries are published to and read from. Signed into the schema, so a reply can't be sent anywhere else. - No relays named, so a client will pick its own. - New field - Edit field - Field name - eg. watchUrl - Label - eg. Watch / reference url - Placeholder - eg. https://youtube.com/watch?v=… - Type - An entry must have this field - Writes to tag - Defaults to the field name. "content" means the event's body. - Marker - eg. watch - The tag's third element, which tells fields sharing a tag apart. - Maximum characters - Maximum value - Minimum value - Options - One per line, or separated by commas. - https only - May appear more than once - Pattern - A regular expression the whole value must match. - Filled in by the app, never asked for - Hint - Helper text under the input. - Done - Remove field - A field needs a name. - A field name is one word, with no spaces. - A field named that already exists. - An identifier is required. - The identifier must be at most %1$s characters. - A title is required. - The title must be at most %1$s characters. - A name is required. - The name must be at most %1$s characters. - A description is required. - The description must be at most %1$s characters. - Say who may suggest to this list. - The picture must be an https url. - That isn't a domain name. - A schema needs at least one field. - One of the relays isn't a relay address. - eg. 2100 - eg. 200 - eg. 1900 - eg. movie, documentary, short - eg. [A-Za-z]{2,8} - Text - Long text - Token - Url - Image - One of a list - Year - Duration in seconds - Number - Propose event - Paste an unsigned Nostr event for the group to sign. It is filed by kind: a profile (kind 0), a post (kind 1), a relay list, or a curated schema (kind 31889). - The group signs it, so it takes a quorum. The author is the group and the time is now, whatever the paste says about either. - This group has no shared key, so it cannot sign an event. Run a shared key ceremony first. - Couldn't ask the group to sign this event. - Unsigned event - {"kind": 1, "tags": [], "content": "…"} - What the group would sign - Kind %1$s - That isn't JSON. - An event is a JSON object with a kind, tags and content. - An event needs a kind. - Kind %1$s has nowhere to go on the group's screen. It can file kinds %2$s. - Tags must be a list of lists of strings. - Content must be text. - The content isn't a profile. A kind 0 carries a JSON object of profile fields. - This schema can't be signed as it is: - View suggestions - Suggestions - Everything anyone has suggested to this list, newest first. Only the group can take one up into the list. - 1 suggestion - %1$s suggestions - %1$s curated - Read from %1$s - Asking the list's relays… - Nobody has suggested anything to this list yet. - Ask again - Curated - Suggested by %1$s on %2$s - Taken up by the group into the list. - Accept this suggestion - Revise the list's entry - - Take this suggestion into %1$s, edited as the group wants it. The entry keeps the suggestion's identifier and points back at it, so the suggester is credited. - The group signs an entry, so it takes a quorum. Nothing changes until one has. - This group has no shared key, so it cannot sign an entry. Run a shared key ceremony first. - The group already has an entry for this. Proposing again revises it in place rather than adding a second one. - Propose entry - Couldn't ask the group to sign this entry. Please try again. - This entry can't be signed as it is: - The entry can't be signed as it is. - Kept from the suggestion - Filled in by the suggester's app and kept as they came, the identifier among them, so that an entry accepted twice lands on the same coordinate. - Add another - This field is required. - Only one value for this field. - One word, without spaces. - That is not a web address. - That must be an https address. - Pick one of the options. - A whole number. - More than zero. - At least %1$s. - At most %1$s. - At most %1$s characters. - Must match %1$s. - At least one of these is needed: %1$s. - %1$s: %2$s - - Curated entries - This group hasn't accepted anything into a list yet. An entry a quorum signs is shown here, and reaches the list's readers once it is broadcast. - - No entries yet - 1 entry - %1$s entries - In %1$s - Suggested by %1$s Broadcast Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours. - Where the group has said it lives is filled in. Add or remove relays before sending. + The relays this app publishes to are filled in. Add or remove relays before sending. No relays to broadcast to. Add one above. Add a relay to broadcast to. Sending… diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt index 33c48367..61775857 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt @@ -77,17 +77,6 @@ interface NostrEventDao { query: RoomRawQuery ): List - /** - * The same query, watched. Observed tables named explicitly because a raw query cannot - * infer them: the events themselves, and the profiles joined onto them, so a row whose - * author's kind 0 arrives after the row does re-emits with the name filled in. - */ - @Transaction - @RawQuery(observedEntities = [NostrEvent::class, press.mantra.compose.database.model.Profile::class]) - fun observeNostrEventsMatchingFilter( - query: RoomRawQuery - ): Flow> - @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") suspend fun getNostrEvents( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 8221ba82..f6db7723 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -20,13 +20,6 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents @@ -390,82 +383,6 @@ data class ChatMessage( */ const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" - /** - * The group signed what it says about itself on nostr. - * - * A system line rather than a bubble, for the reason every group-signed - * line is one: nobody said it, the group signed it. Whoever filled the - * form in proposed it and a quorum agreed, and the transcript of that - * happening is the signing session's own -- this is the line saying what - * the group now is, which is worth having in the room because a rename is - * the kind of thing members should not have to go looking for. - * - * Written only for a profile the *room* signed. A kind:0 authored by - * anybody else that reaches this room is a member's own profile - * travelling as a rumor, which is not the group saying anything. - */ - const val TYPE_GROUP_PROFILE_SIGNED = "groupProfileSigned" - - /** - * The group signed where it lives on nostr. - * - * One line per list, so a session that changed three of them writes three. - * They are not three accounts of one thing -- each names which list was - * agreed, which is the part a reader needs -- and `applyInnerEvent` is - * handed one event at a time with no way to know it was in a batch anyway. - */ - const val TYPE_GROUP_RELAYS_SIGNED = "groupRelaysSigned" - - /** - * Every line about the group's own nostr identity, for the one check the - * transcript dispatches on. - * - * The profile and the relay lists together, because they are the same kind - * of statement -- the group's account of itself, signed by the group -- and - * the transcript does the same thing with both. A type missing from here - * renders as a chat bubble, silently, looking exactly like somebody having - * said "The group is now called Translation collective". - */ - /** - * The group signed something to say publicly, in its own name. - * - * A system line rather than a bubble, like the other two -- nobody said it, - * the group signed it -- and the line names the post rather than repeating - * it, because the post itself is on the group's screen where it can be read - * whole. A quote here would be the same words twice with the second copy - * unable to say who agreed to them. - */ - const val TYPE_GROUP_POST_SIGNED = "groupPostSigned" - - /** - * The group signed the schema of a list it curates. - * - * A system line like the other three, and it names the list rather than - * describing the schema: what a reader of the room needs to know is that - * the group now curates -- or has changed the form for -- a list called - * so-and-so, and the schema itself is on the group's screen where its - * fields can be read. - */ - const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned" - - /** - * The group signed an entry into a list it curates. - * - * A system line like the schema's, and it names the entry and the list - * rather than the fields: what a reader of the room needs to know is - * that the list now stands behind a title, and the entry itself is on - * the queue screen where its fields can be read. - */ - const val TYPE_GROUP_ENTRY_CURATED = "groupEntryCurated" - - val GROUP_IDENTITY_TYPES = setOf( - TYPE_GROUP_PROFILE_SIGNED, - TYPE_GROUP_RELAYS_SIGNED, - TYPE_GROUP_POST_SIGNED, - TYPE_GROUP_SCHEMA_SIGNED, - TYPE_GROUP_ENTRY_CURATED, - ) - /** * Every subgroup line, for the one check the transcript dispatches on. * @@ -1016,22 +933,6 @@ data class ChatMessage( createdAt: Instant, groupSignedEventId: HexKey? = null, ): ChatMessage? { - // The last resort, named because one arm reaches it deliberately rather - // than by falling off the end: a kind:1 the room did not sign is a - // member's own note travelling through the room, and it has always - // rendered as this. See the `TextNoteEvent` arm. - fun unsupported() = ChatMessage( - giftWrapPayloadId = null, - messageType = "unsupported", - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = event.toJson(), - ) - return when (event.kind) { ChatEvent.KIND -> { ChatMessage( @@ -1444,197 +1345,19 @@ data class ChatMessage( ) } - // The group saying who it is on nostr. A room's id is the pubkey - // it signs as, so a group has an identity from the moment it has a - // key; this is the kind:0 describing it, signed by the group's own - // quorum like everything else it says. - // - // **Only when the room itself signed it.** This arm is reached both - // from a completed signing session, where the author is the room by - // construction, and from an arriving inner event, where any member - // could have sent a rumor of this kind -- and it cannot tell which. - // A member's own kind:0 travelling through a room is their profile, - // not the group's, and gets no line here. - // - // No row is written. The event is already on file as a - // `GroupSignedEvent` -- `FrostSigningManager.complete` records the - // batch before it applies it -- and `GroupNostrProfile` reads the - // group's profile straight off those. A `Profile` row is not an - // option anyway: it hangs off `NostrEvent` by foreign key, and a - // group-signed event is not one. - MetadataEvent.KIND -> { - if (!event.pubKey.equals(groupId, ignoreCase = true)) return null - - val profile = GroupNostrProfile.of( - signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId), - chatRoomId = groupId, - ) ?: return null - + else -> { ChatMessage( giftWrapPayloadId = null, - messageType = TYPE_GROUP_PROFILE_SIGNED, + messageType = "unsupported", marmotGroupEventId = marmotGroupEventId, marmotInnerEventId = marmotInnerEventId, senderPublicKey = senderPublicKey, isUserMessage = isUserMessage, chatRoomId = groupId, createdAt = createdAt, - content = profile.name()?.let { "The group is now called $it" } - ?: "The group signed a new profile for itself" + content = event.toJson(), ) } - - // The group saying something publicly, in its own name -- which - // is a different thing from a member saying something in the room. - // A room's messages are `ChatEvent`s; this is a kind:1 authored by - // the room's own key, so any reader can check the group said it and - // that no single member could have. - // - // Verified like the arms below and for the same reason: a rumor - // carries an empty signature and any pubkey its sender likes, so an - // author check alone would let one member put words in the group's - // mouth in its own transcript. - // - // **Unlike those arms, a kind:1 that fails falls through to - // `unsupported` rather than vanishing.** A member's kind:0 or relay - // list passing through a room is identity plumbing nobody sent on - // purpose and silence is the right treatment; a kind:1 is content, - // somebody meant it, and it has rendered as an unsupported event - // since before any of this existed. Dropping it here would be this - // change quietly deleting messages it has no business touching. - TextNoteEvent.KIND -> { - val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) - if (!signed.verifies()) return unsupported() - - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_GROUP_POST_SIGNED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "The group posted in its own name" - ) - } - - // The group saying where it lives on nostr: which relays carry - // its work, take its messages, answer a search of it, and which it - // will not talk to. - // - // **Verified, not just attributed.** This arm is reached from a - // completed signing session, where the author is the room by - // construction, and from an arriving inner event, where it is - // whatever the sender wrote -- and it cannot tell which. A rumor - // carries an empty signature and any pubkey its sender likes, so an - // author check alone would let one member put "the group signed its - // general relay list" in the transcript. `verifies` is what makes - // the line mean what it says: the room's key over these tags. - // - // A member's own relay list travelling through the room fails the - // author half and gets no line, which is right -- it is their list, - // not the group's. - // - // No row is written, and nothing is parsed beyond the check. The - // events are already on file as `GroupSignedEvent` and - // `GroupRelayList` reads the lists straight off those; this arm - // exists so a signed relay list is a line in the transcript rather - // than raw JSON in a bubble. - in GroupRelaySet.KINDS -> { - val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) - if (!signed.verifies()) return null - - val set = GroupRelaySet.entries.firstOrNull { it.kind == event.kind } - ?: return null - - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_GROUP_RELAYS_SIGNED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "The group signed its ${set.name.lowercase()} relay list" - ) - } - - // The group defining a list it curates: the kind 31889 that - // suggestions from anyone reply to and only the group's own key can - // accept into the list. - // - // Verified and parsed, and null on either failing, the way the - // profile arm is. A rumor of this kind from a member is not the - // group curating anything, and a schema the room signed but that no - // client could act on -- no visibility, no fields -- is not worth a - // line saying it did. `GroupCuratedSchema` refuses the same event - // on the group's screen, so the transcript and the screen agree. - CuratedSchemaEvent.KIND -> { - val curated = GroupCuratedSchema.of( - signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId), - chatRoomId = groupId, - ) ?: return null - - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_GROUP_SCHEMA_SIGNED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "The group signed the curated schema for " + - curated.schema.displayName() - ) - } - - // The group taking an entry into a list it curates: the kind - // 31890 that only its own key can publish, and the thing the - // queue screen marks a suggestion "curated" by once it is read - // back off a relay. - // - // Verified, for the reason the schema arm is: a rumor of this - // kind from a member is not the group curating anything. Named - // after the list where this device holds its schema -- looked - // up by the coordinate the entry replies to -- and after the - // entry alone where it does not, since the entry is what was - // signed either way. - CuratedEntryEvent.CANONICAL_KIND -> { - val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) - if (!signed.verifies()) return null - - val entry = CuratedEntryEvent.headline(event) - val title = entry.title.ifBlank { entry.identifier }.ifBlank { "an entry" } - - val list = runCatching { - GroupCuratedSchema.newestPerListAmong( - signedEvents = database.groupSignedEventDao() - .getByChatRoomIdAndKind(groupId, CuratedSchemaEvent.KIND), - chatRoomId = groupId, - ).firstOrNull { it.isRepliedToBy(event.tags) } - }.getOrNull() - - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_GROUP_ENTRY_CURATED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = if (list != null) { - "The group accepted $title into ${list.schema.displayName()}" - } else { - "The group accepted $title into a list it curates" - } - ) - } - - else -> unsupported() } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt index 8b514f6b..007681a1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt @@ -478,17 +478,6 @@ data class NostrEvent( } } - /** The row as the event it was stored from, for readers written against the wire shape. */ - fun toEvent(): Event = Event( - id = id, - pubKey = pubKey, - createdAt = createdAt.epochSeconds, - kind = kind, - tags = tags, - content = content, - sig = sig, - ) - fun toTextNoteEvent(): TextNoteEvent { return TextNoteEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt index 1d0a7083..9ca34fd7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt @@ -117,8 +117,7 @@ data class LocalChatRoom( lastChatMessage.messageType in ChatMessage.FROST_TYPES || lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES || lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES || - lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES || - lastChatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES + lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES ) { val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES || lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index fb511cec..d0143ec3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -9,14 +9,6 @@ import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -33,8 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -130,75 +120,6 @@ class DatabaseChatRepository( null } - override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = try { - GroupNostrProfile.newestAmong( - signedEvents = database.groupSignedEventDao() - .getByChatRoomIdAndKind(chatRoomId, MetadataEvent.KIND), - chatRoomId = chatRoomId, - ) - } catch (e: Throwable) { - // Reading one parses content off the wire and checks a signature, and a - // room that shows no profile is wrong and survivable where one that will - // not open is neither -- the same trade the readings above make. - logger.e("Error reading the nostr profile of $chatRoomId", e) - null - } - - override suspend fun groupRelayLists(chatRoomId: String): List = try { - // One query per set rather than one for the room: the four kinds are - // indexed and a room's whole signed history is every artifact, chunk and - // translation it has ever agreed. - GroupRelaySet.entries.map { set -> - GroupRelayList.newestAmong( - signedEvents = database.groupSignedEventDao() - .getByChatRoomIdAndKind(chatRoomId, set.kind), - chatRoomId = chatRoomId, - set = set, - ) - } - } catch (e: Throwable) { - logger.e("Error reading the relay lists of $chatRoomId", e) - // Four unsigned, empty sets: the screen draws "not set yet" rather than - // failing to draw, which is the trade every reading above makes. - GroupRelayList.allAmong(emptyList(), chatRoomId) - } - - override suspend fun groupPosts(chatRoomId: String): List = try { - GroupPost.newestFirstAmong( - signedEvents = database.groupSignedEventDao() - .getByChatRoomIdAndKind(chatRoomId, TextNoteEvent.KIND), - chatRoomId = chatRoomId, - ) - } catch (e: Throwable) { - logger.e("Error reading the posts of $chatRoomId", e) - emptyList() - } - - override suspend fun groupCuratedSchemas(chatRoomId: String): List = try { - GroupCuratedSchema.newestPerListAmong( - signedEvents = database.groupSignedEventDao() - .getByChatRoomIdAndKind(chatRoomId, CuratedSchemaEvent.KIND), - chatRoomId = chatRoomId, - ) - } catch (e: Throwable) { - logger.e("Error reading the curated schemas of $chatRoomId", e) - emptyList() - } - - override suspend fun groupCuratedEntries(chatRoomId: String): List = try { - // The schemas first, because an entry is read against the list it - // replies to and there is nothing to read one by without them. - GroupCuratedEntry.newestPerEntryAmong( - signedEvents = database.groupSignedEventDao() - .getByChatRoomIdAndKind(chatRoomId, CuratedEntryEvent.CANONICAL_KIND), - chatRoomId = chatRoomId, - schemas = groupCuratedSchemas(chatRoomId), - ) - } catch (e: Throwable) { - logger.e("Error reading the curated entries of $chatRoomId", e) - emptyList() - } - override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId } } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 44c45d97..1d82481b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -749,12 +749,6 @@ class DatabaseNostrRepository( } } - override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { - return database.nostrEventDao().observeNostrEventsMatchingFilter( - NostrEventFilterQuery.build(synchronizationFilter) - ) - } - override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt index 070e548a..b3fdf441 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Member.kt @@ -9,23 +9,6 @@ private const val SHORTENED_PUBLIC_KEY_LENGTH = 8 /** A public key cut to something readable, for where no profile has arrived yet. */ fun HexKey.shortened(): String = take(SHORTENED_PUBLIC_KEY_LENGTH) -/** - * A long hex value in groups of eight, which is what makes it readable and what - * lets it wrap at all. - * - * Both halves matter. A 64-character key is compared against another member's - * screen a character at a time, and the eye loses its place in an undivided run - * of hex -- the same reason a fingerprint or an account number is grouped. - * - * And a run of hex has no space in it, so Compose has nowhere to break the line: - * it lays the whole thing out on one and lets it run off the side of the sheet, - * where the end of the key cannot be read at all. The spaces are the break - * opportunities. Nothing is copied from these -- the copy button takes the - * signed event, which is not grouped -- so the display can be shaped for reading - * without a paste losing the value. - */ -fun HexKey.inComparableGroups(): String = chunked(8).joinToString(" ") - /** * What to call the member behind this public key on screen. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt deleted file mode 100644 index a7c1ff88..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt +++ /dev/null @@ -1,115 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.GENESIS_AT -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.Profile -import press.mantra.compose.database.model.intermdiate.LocalNostrEvent -import press.mantra.compose.extensions.shortened -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import kotlin.time.Instant - -/** - * One suggestion to a list the group curates, as somebody published it and as - * this device has read it. - * - * The schema is the group asking; these are the answers. Anyone may publish one - * -- that is the point of a list -- so unlike everything else on the group's - * screen a suggestion is not the group's word and was never signed in the room. - * It is a `NostrEvent` off a relay, signature-checked on the way in like every - * other, and then checked against the schema it replies to, because a relay is - * an untrusted place and an entry that does not satisfy the schema is not shown - * with the bad parts blanked out. See [CuratedEntryEvent]. - * - * ### The queue and the list - * - * The NIP's words: the canonical entries are the list, and the suggestions are - * its queue. This is the queue. A suggestion is marked [isCurated] when a - * canonical entry the *group* signed shares its identifier -- the coordinate - * both land on -- which is how a reader tells what the group has already taken - * up from what is still waiting. Only the group's own canonical entries count: - * curation is the one power a schema does not delegate, and a kind 31890 from - * anybody else is somebody else's list. - */ -data class CuratedSuggestion( - /** The entry, keyed by the schema's field names. */ - val entry: CuratedEntry, - /** The event as it was stored, for the id and the tags: what a canonical entry points back at. */ - val event: NostrEvent, - /** The suggester's profile where one has arrived, else null. */ - val suggester: Profile?, - /** Whether a canonical entry by the group shares this suggestion's identifier. */ - val isCurated: Boolean, -) { - val id: String get() = event.id - - val suggestedAt: Instant get() = event.createdAt - - /** - * What to call the suggester: their name where a profile has arrived and is - * a real one, else a short key. A placeholder profile is the one the indexer - * writes the moment a pubkey is first seen, and it is named "LOADING..." -- - * which is not a name. - */ - fun suggesterName(): String = - suggester?.takeUnless { it.createdAt == GENESIS_AT }?.humanReadableNameOrPubkey() - ?: entry.pubKey.shortened() - - companion object { - /** - * The queue of [schema], read off [events], newest first. - * - * Two readings over the same rows. Every kind 31888 that satisfies the - * schema and replies to its coordinate is a suggestion; every kind 31890 - * that does the same *and* is signed by the group is a canonical entry. - * Both kinds are addressable, so among events sharing a coordinate the - * newest is the entry and the rest are its history -- the client-side - * merge that realises an edit, since different relays hold different - * latest versions. Then the canonical identifiers mark the suggestions. - * - * Newest first because that is the order a queue is read in, and the id - * breaking every tie so two devices reading the same events in different - * orders show the same thing. - */ - fun queueOf(schema: GroupCuratedSchema, events: List): List { - val coordinate = schema.coordinate() - - val entries = events.mapNotNull { local -> - val event = local.nostrEvent.toEvent() - - when (event.kind) { - CuratedEntryEvent.SUGGESTION_KIND -> - CuratedEntryEvent.suggestion(event, schema.schema, coordinate) - CuratedEntryEvent.CANONICAL_KIND -> - CuratedEntryEvent.canonical(event, schema.schema, coordinate, curator = schema.publicKey) - else -> null - }?.let { entry -> local to entry } - } - .groupBy { (_, entry) -> entry.coordinate() } - .values - .map { versions -> - versions.maxWith(compareBy({ (_, entry) -> entry.createdAt }, { (_, entry) -> entry.id })) - } - - val curatedIdentifiers = entries - .filter { (_, entry) -> entry.isCanonical } - .map { (_, entry) -> entry.identifier } - .toSet() - - return entries - .filterNot { (_, entry) -> entry.isCanonical } - .map { (local, entry) -> - CuratedSuggestion( - entry = entry, - event = local.nostrEvent, - suggester = local.profile, - isCurated = entry.identifier in curatedIdentifiers, - ) - } - .sortedWith( - compareByDescending { it.entry.createdAt } - .thenByDescending { it.entry.id } - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt deleted file mode 100644 index 04caf549..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt +++ /dev/null @@ -1,134 +0,0 @@ -package press.mantra.compose.nostr - -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import kotlin.time.Instant - -/** - * A canonical entry the group signed: a suggestion it accepted into one of - * the lists it curates, as the list's own word. - * - * Kind 31890 -- see [CuratedEntryEvent] -- authored by the room's own key, - * which is the one key a list's readers accept a canonical entry from. Where - * the schema is the group asking and the queue is what strangers answered, - * this is what the group *decided*: the entries the list stands behind, each - * one a quorum's signature over a suggestion, edited or not, with a pointer - * back at who suggested it. - * - * ### Read against the list it replies to - * - * An entry replies to a schema by coordinate, so the reading needs the - * group's schemas as well as its signed events: the one whose coordinate the - * entry's `a` root names is the list it belongs to, and the entry is then - * checked against that list's fields exactly as `CuratedSuggestion.queueOf` - * checks a copy that came back off a relay. The two readings agree by - * construction, so what this screen shows as accepted is what the queue will - * mark as curated once the entry has been broadcast and read back. - * - * An entry replying to a list this device holds no schema for is not shown: - * without the schema there are no fields to read it by, and no way to say - * which list it is in. - * - * ### One per coordinate - * - * The kind is addressable, so re-curating an entry -- to take in a - * suggester's edit, or to fix the group's own -- is a newer event under the - * same `d`, and the newest is the entry the way the newest schema is the - * list. The coordinate is `31890::` whatever list the entry replies - * to, so the same `d` accepted into two of the group's lists is one - * coordinate and the newer event replaces the older, on relays and here - * alike. An identifier is the entry's rather than the list's, which is the - * reference client's reading too: its `d` is an external id that names the - * same film in every list. - * - * ### Nothing here has reached a relay - * - * The same caveat as the schema, and here it is the one that matters: the - * queue screen marks a suggestion curated only from a copy read back off the - * list's relays, so until a member broadcasts an entry from this section the - * queue still shows it waiting. The broadcast button under each card is how - * it gets there. - */ -data class GroupCuratedEntry( - /** The group's statement, whole, with the signature that makes it one. */ - val signedEvent: GroupSignedEvent, - /** The list the entry is in, as the group last signed its schema. */ - val list: GroupCuratedSchema, - /** What the statement says, keyed by the list's fields, and where it came from. */ - val entry: CuratedEntry, -) { - /** The group's nostr identity, which for a derived room is also its id. */ - val publicKey: HexKey get() = signedEvent.publicKey - - /** When the group signed it, which is the order entries are read in. */ - val signedAt: Instant get() = signedEvent.createdAt - - /** The entry's `d`, which with the group's key is its coordinate. */ - val identifier: String get() = entry.identifier - - val title: String get() = entry.title - - /** `31890::`. Two events sharing one are versions of one entry. */ - fun coordinate(): String = entry.coordinate() - - companion object { - /** - * The reading of one signed event against the group's [schemas], or - * null when it is not one of these. - * - * Four ways to be null and they are all the same refusal: the wrong - * kind, a signature that does not check out as [chatRoomId]'s, a root - * naming no list this device holds, or an entry the list's own fields - * refuse. A caller gets an entry the group really signed into a list it - * really curates, or gets nothing. - */ - fun of( - signedEvent: GroupSignedEvent, - chatRoomId: String, - schemas: List, - ): GroupCuratedEntry? { - if (signedEvent.kind != CuratedEntryEvent.CANONICAL_KIND) return null - if (!signedEvent.verifies()) return null - - val list = schemas.firstOrNull { it.isRepliedToBy(signedEvent.tags) } ?: return null - - val entry = CuratedEntryEvent.canonical( - event = signedEvent.toEvent(), - schema = list.schema, - coordinate = list.coordinate(), - curator = list.publicKey, - ) ?: return null - - return GroupCuratedEntry(signedEvent = signedEvent, list = list, entry = entry) - } - - /** - * The newest version of every entry [chatRoomId] signed among - * [signedEvents], most recently signed first. - * - * Newest per coordinate because the kind is addressable and a - * re-curation is a newer event under the same `d`; newest first so that - * what the group last decided is at the top, and the id breaking every - * tie so that two devices reading the same events in different orders - * show the same thing. - */ - fun newestPerEntryAmong( - signedEvents: List, - chatRoomId: String, - schemas: List, - ): List = - signedEvents - .mapNotNull { of(it, chatRoomId, schemas) } - .groupBy { it.coordinate() } - .values - .map { versions -> - versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id })) - } - .sortedWith( - compareByDescending { it.signedAt } - .thenByDescending { it.signedEvent.id } - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt deleted file mode 100644 index 1622e453..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt +++ /dev/null @@ -1,206 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedEntryProblem -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSuggestionRef -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import kotlin.time.Instant - -/** - * A curated schema the group signed: a list it curates, as the group defined it. - * - * Kind 31889 -- see [CuratedSchemaEvent] -- authored by the room's own key. That - * makes the group the curator of the list the schema describes: anyone may reply - * to it with a suggestion, and only an event signed by the same key counts as the - * list itself. For a group that means the same thing everything else on its screen - * means: a quorum decides what goes in. - * - * ### Several per group, one per identifier - * - * Unlike a profile, of which a group has one, a group may curate several lists, - * and each is its own coordinate `31889::`. So the reading is per - * identifier: among the events sharing a `d` the newest wins, the way it does for - * a profile, because the kind is addressable and re-signing one is how a schema - * is edited. Two different identifiers are two lists and both are shown. - * - * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the - * reasons `GroupNostrProfile` gives at length: a group-signed event is not a - * `NostrEvent`, and only an event the room itself signed is the group speaking. - * - * ### Nothing here has reached a relay - * - * The same caveat as the profile, the relay lists and the posts. A schema is the - * one statement here that exists to be *replied to* by strangers, so until it is - * published the list it defines has no queue -- the relays it names say where - * suggestions would go, and nothing yet puts the schema there for them to find. - * The queue is read all the same, by `CuratedSuggestion.queueOf` over whatever - * those relays hold for [coordinate], so a schema that reached a relay by some - * other road has its suggestions shown here. - */ -data class GroupCuratedSchema( - /** The group's statement, whole, with the signature that makes it one. */ - val signedEvent: GroupSignedEvent, - /** What the statement says, already checked and normalized. */ - val schema: CuratedSchema, -) { - /** The group's nostr identity, which for a derived room is also its id. */ - val publicKey: HexKey get() = signedEvent.publicKey - - /** When the group signed it, which is what decides the newest of two. */ - val signedAt: Instant get() = signedEvent.createdAt - - /** The schema's `d`, and the last part of its coordinate. */ - val identifier: String get() = schema.identifier - - /** What a suggestion's `a` root names to reply to this list. */ - fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier) - - /** - * Whether an entry carrying [tags] replies to this list: its `a` tags - * include this coordinate. How a reader holding several of the group's - * schemas tells which one an entry belongs to. - */ - fun isRepliedToBy(tags: Array>): Boolean = - tags.any { it.size > 1 && it[0] == COORDINATE_TAG && it[1] == coordinate() } - - /** - * What to ask a relay for to read this list's queue: the NIP's two queries, - * as one request. - * - * Everything suggested to the list is whatever anyone published in reply - * to the coordinate; the list itself is what the *group* published in reply - * to it. The `authors` on the second is redundant with the rule that only - * the curator's canonical entries count -- `CuratedSuggestion.queueOf` - * enforces that on the way in regardless -- and is there to spare the relay - * the work. Both land in the same table, and [queueReadFilter] is how they - * are read back. - */ - fun queueFilters(): Array = arrayOf( - SynchronizationFilter( - kinds = arrayOf(schema.suggestionKind), - tags = mapOf(COORDINATE_TAG to listOf(coordinate())), - limit = QUEUE_LIMIT, - ), - SynchronizationFilter( - kinds = arrayOf(CuratedSchemaEvent.CANONICAL_KIND), - authors = arrayOf(publicKey), - tags = mapOf(COORDINATE_TAG to listOf(coordinate())), - limit = QUEUE_LIMIT, - ), - ) - - /** - * What to watch locally for the same rows: both kinds, replying to the - * coordinate. One filter rather than two because the local store is a - * single table and the reading tells the kinds apart itself. - */ - fun queueReadFilter(): SynchronizationFilter = SynchronizationFilter( - kinds = arrayOf(schema.suggestionKind, CuratedSchemaEvent.CANONICAL_KIND), - tags = mapOf(COORDINATE_TAG to listOf(coordinate())), - ) - - /** - * The event to ask the group to sign to take an entry into the list: - * [values] as this schema lays them out, rooted at [coordinate], with the - * group as the curator and [source] as the suggestion it came from. - * - * The group is the curator, so its key is the `p` on the root and the only - * author [verifyCanonical] will accept. See [CuratedEntryEvent.canonicalTemplate] - * for what is and is not done to the values on the way through. - */ - fun canonicalTemplate( - values: Map>, - source: CuratedSuggestionRef?, - ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate( - schema = schema, - coordinate = coordinate(), - curator = publicKey, - values = values, - source = source, - ) - - /** - * Every reason this list's readers would refuse [template] as one of its - * own entries, or nothing. - * - * The same check `CuratedSuggestion.queueOf` makes on a kind 31890 off a - * relay, made before the group is asked to sign one: a signature is the - * most expensive thing this app does, and an entry the queue would then - * refuse to count is a quorum spent on nothing. - */ - fun verifyCanonical(template: EventTemplate<*>): List = - CuratedEntryEvent.verifyCanonical( - event = CuratedEntryEvent.preview(template, publicKey), - schema = schema, - coordinate = coordinate(), - curator = publicKey, - ) - - companion object { - /** The tag a reply carries its schema's coordinate in. */ - private const val COORDINATE_TAG = "a" - - /** - * How much of a queue one request asks for: the reference client's - * window, and more than any list here is likely to have. NIP-01 scopes - * a limit to the stored events a relay sends before EOSE, so a longer - * queue is read on the next visit rather than never. - */ - private const val QUEUE_LIMIT = 500 - - /** - * The reading of one signed event, or null when it is not one of these. - * - * Three ways to be null and they are all the same refusal: the wrong - * kind, a signature that does not check out as [chatRoomId]'s, or tags - * that do not make a usable schema. A caller gets a schema the group - * really signed and a client could really act on, or gets nothing. - */ - fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupCuratedSchema? { - if (signedEvent.kind != CuratedSchemaEvent.KIND) return null - if (!signedEvent.verifies()) return null - - val schema = CuratedSchemaEvent.parse(signedEvent.toEvent()) ?: return null - - return GroupCuratedSchema(signedEvent = signedEvent, schema = schema) - } - - /** - * The newest schema per identifier [chatRoomId] signed among - * [signedEvents], most recently signed list first. - * - * Newest per identifier because the kind is addressable and an edit is a - * newer event under the same `d`; newest list first so that an edit - * surfaces, and the id breaking every tie so that two devices reading the - * same events in different orders show the same thing. - */ - fun newestPerListAmong( - signedEvents: List, - chatRoomId: String, - ): List = - signedEvents - .mapNotNull { of(it, chatRoomId) } - .groupBy { it.identifier } - .values - .map { versions -> - versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id })) - } - .sortedWith( - compareByDescending { it.signedAt } - .thenByDescending { it.signedEvent.id } - ) - - /** - * The event to ask the group to sign for [schema]. - * - * Built from the schema alone -- see [CuratedSchemaEvent.template] for - * why an edit does not carry the last event's tags forward. - */ - fun template(schema: CuratedSchema): EventTemplate<*> = CuratedSchemaEvent.template(schema) - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt deleted file mode 100644 index 52c1ca3c..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt +++ /dev/null @@ -1,188 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.network.serialization.CommonJson -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSchemaProblem -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.Kind -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull - -/** - * An unsigned event a member pasted for the group to sign, read and checked - * before a quorum is asked for it. - * - * The typed editors -- the profile form, the post composer, the relay and - * schema editors -- each build one kind of event and know what it needs. This - * is the untyped way in: the event as JSON, the way another tool prints it - * (`npm run schema:dry` in the bitcoin.mov repo prints exactly the schema event - * it would publish), pasted whole. What comes out is the same thing the editors - * produce, a kind, tags and content for `FrostSigningRepository.proposeSigning`, - * and it is filed the same way when the quorum signs: `FrostSigningManager.complete` - * records it as a `GroupSignedEvent` and the group's screen reads its profile, - * posts, relay lists and schemas straight off those by kind. - * - * ### What is ignored - * - * `id`, `pubkey`, `sig` and `created_at`. The group is the author -- the - * session resolves the room's key and hashes the id over it -- and the time is - * when the session opens, which is what every typed editor does too. A pasted - * `created_at` in particular has to go: the kinds here are replaceable or - * addressable, so a stale one would make the new event *older* than the last and - * the proposal would look as though it did nothing. The screen says so. - * - * ### What is refused, and why before rather than after - * - * Only kinds the group's screen has a place for, and only events that place - * would accept. A signature is the most expensive thing this app does, and the - * readers refuse rather than repair: a kind 0 whose content is not a profile, - * a blank post, a schema with no visibility would each be signed, filed and - * shown nowhere, with nothing to tell the member their quorum was spent on - * nothing. So every check a reader makes is made here first, on the paste. - */ -object GroupEventProposal { - /** - * The kinds a pasted event may be: exactly the ones the group's screen reads. - * - * Not the kinds `ChatMessage.applyInnerEvent` has an arm for. A subgroup - * certificate or a key state event has invariants a paste cannot be trusted - * to meet and a place in the room's life that a form is not, and the - * nip30303 kinds have editors of their own. The line is "has a section on the - * group's screen", because that is where a member goes to see it landed. - */ - val ACCEPTED_KINDS: Set = - setOf(MetadataEvent.KIND, TextNoteEvent.KIND, CuratedSchemaEvent.KIND) + GroupRelaySet.KINDS - - sealed interface Reading - - /** An event the group could sign and the screen could show. */ - class Ready( - val kind: Kind, - val tags: Array>, - val content: String, - ) : Reading { - /** - * The event as the signing screen will describe it, for a preview before - * the button is pressed. The id, author and signature are placeholders: - * `ProposedEvent.summarize` reads none of them. - */ - fun preview(): Event = Event( - id = "", - pubKey = "", - createdAt = 0, - kind = kind, - tags = tags, - content = content, - sig = "", - ) - } - - /** Why the paste cannot be proposed as it is. */ - sealed interface Refused : Reading { - data object NotJson : Refused - - /** JSON, but an array, a string, a number -- anything but an object. */ - data object NotAnObject : Refused - - data object NoKind : Refused - - data class KindNotAccepted(val kind: Kind) : Refused - - /** `tags` present but not a list of lists of strings. */ - data object TagsMalformed : Refused - - /** `content` present but not a string. */ - data object ContentNotText : Refused - - /** A kind 0 whose content will not parse as a profile. */ - data object ProfileUnreadable : Refused - - /** A kind 1 with nothing to say, which the posts reader would drop. */ - data object PostBlank : Refused - - /** A kind 31889 the schema reader would refuse, with its reasons. */ - data class SchemaUnusable(val problems: List) : Refused - } - - private const val KEY_KIND = "kind" - private const val KEY_TAGS = "tags" - private const val KEY_CONTENT = "content" - - /** The paste as something the group could sign, or the reason it is not. */ - fun read(json: String): Reading { - val element = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() - ?: return Refused.NotJson - - val obj = element as? JsonObject ?: return Refused.NotAnObject - - // A number or a numeric string: relays write the former and a hand - // types either, and there is nothing to be gained by refusing "1". - val kind = (obj[KEY_KIND] as? JsonPrimitive)?.intOrNull ?: return Refused.NoKind - if (kind !in ACCEPTED_KINDS) return Refused.KindNotAccepted(kind) - - val tags = when (val raw = obj[KEY_TAGS]) { - null -> emptyArray() - else -> readTags(raw) ?: return Refused.TagsMalformed - } - - val content = when (val raw = obj[KEY_CONTENT]) { - null -> "" - else -> (raw as? JsonPrimitive)?.takeIf { it.isString }?.content - ?: return Refused.ContentNotText - } - - return when (kind) { - // An empty kind 0 is a profile the group has wiped, which it is - // entitled to do; content that is not JSON is not a profile at all. - // The same line `GroupNostrProfile.of` draws. - MetadataEvent.KIND -> if (metadataOf(tags, content) == null) { - Refused.ProfileUnreadable - } else { - Ready(kind, tags, content) - } - - // The line `AddGroupPostViewModel.proposePost` draws, for the reason - // it gives: a blank post cannot be told from a broken one and, kind:1 - // not being replaceable, cannot be un-said. - TextNoteEvent.KIND -> if (content.isBlank()) { - Refused.PostBlank - } else { - Ready(kind, tags, content) - } - - CuratedSchemaEvent.KIND -> CuratedSchemaEvent.read(tags, content).problems().let { problems -> - if (problems.isEmpty()) Ready(kind, tags, content) else Refused.SchemaUnusable(problems) - } - - // A relay list with no relay in it is a list the group has withdrawn, - // which the relays card shows as "no relays" rather than as nothing. - else -> Ready(kind, tags, content) - } - } - - private fun readTags(raw: JsonElement): Array>? { - val outer = raw as? JsonArray ?: return null - - return outer.map { tag -> - val inner = tag as? JsonArray ?: return null - inner.map { value -> - (value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull ?: return null - }.toTypedArray() - }.toTypedArray() - } - - private fun metadataOf(tags: Array>, content: String) = MetadataEvent( - id = "", - pubKey = "", - createdAt = 0, - tags = tags, - content = content, - sig = "", - ).contactMetaData() -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt deleted file mode 100644 index f66c75d7..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt +++ /dev/null @@ -1,196 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import kotlin.time.Instant - -/** - * The kind:0 a group signed about itself, as the group signed it. - * - * A Marmot room's id *is* the nostr pubkey it signs as -- see - * `docs/shared-key-derivation.md`, where those are one value -- so the group has - * an identity on nostr whether or not it has ever said anything about it. This is - * what it says: a name, a picture, a line about what the group is for, authored by - * the group rather than by whichever member typed it. - * - * ### Why this reads off [GroupSignedEvent] and not off `Profile` - * - * A `Profile` row hangs off a `NostrEvent` by foreign key, and a group-signed - * event is not a `NostrEvent`: no member sent it, it never travelled on the wire - * as itself, and the outbound pipeline would re-author it as its sender and strip - * the group's signature off. See `GroupSignedEvent`, which exists for exactly the - * events in that position. So the group's profile lives where the group's other - * signed statements live, and this is the reading of it. - * - * The consequence worth stating: **nothing here reaches a relay by being signed.** - * A member's kind:0 is published and is how the rest of nostr learns their name; a - * group's is signed and kept, and every device that followed the signing session - * -- or was handed the event afterwards -- holds it. It reaches a relay when a - * member sends it there from `BroadcastGroupSignedEventScreen`, which serialises - * the signed row directly rather than making a `NostrEvent` row for an event no - * member authored -- a decision this still does not make. - * - * ### What a reading has to earn - * - * [GroupSignedEvent.verifies] and nothing less: the author has to be the room, the - * id has to be the hash of the fields beside it, and the signature has to verify. - * That is what makes this the *group's* profile rather than a kind:0 that happened - * to be filed against the room -- and it is checkable from the row alone, with no - * ceremony or key state to consult first. - * - * A room never derived from its group's key signs as the bare threshold key rather - * than as its own id, so it has no profile *for its own pubkey* and gets none - * here. That is the derivation's limit rather than this reader's, and it is the - * same limit `GroupSignedEvent.verifies` documents. - */ -data class GroupNostrProfile( - /** The group's statement, whole, with the signature that makes it one. */ - val signedEvent: GroupSignedEvent, - /** Its content parsed: the fields nostr clients read a profile out of. */ - val metadata: UserMetadata, -) { - /** The group's nostr identity, which for a derived room is also its id. */ - val publicKey: HexKey get() = signedEvent.publicKey - - /** When the group signed it, which is what decides the newest of two. */ - val signedAt: Instant get() = signedEvent.createdAt - - /** - * What to call the group, or null when it has published no name. - * - * `display_name` before `name`, which is what `UserMetadata.anyName` does and - * what every other nostr client does. Null rather than the pubkey: a caller - * showing this beside the pubkey would otherwise show it twice. - */ - fun name(): String? = metadata.anyName()?.takeIf { it.isNotBlank() } - - fun about(): String? = metadata.about?.takeIf { it.isNotBlank() } - - fun picture(): String? = metadata.picture?.takeIf { it.isNotBlank() } - - fun banner(): String? = metadata.banner?.takeIf { it.isNotBlank() } - - fun website(): String? = metadata.website?.takeIf { it.isNotBlank() } - - fun nip05(): String? = metadata.nip05?.takeIf { it.isNotBlank() } - - fun lud16(): String? = metadata.lud16?.takeIf { it.isNotBlank() } - - /** The event as the group signed it, for [MetadataEvent.updateFromPast]. */ - fun asMetadataEvent(): MetadataEvent = signedEvent.toEvent().let { event -> - MetadataEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig, - ) - } - - companion object { - /** - * The reading of one signed event, or null when it is not one of these. - * - * Three ways to be null and they are all the same refusal: the wrong kind, - * a signature that does not check out as [chatRoomId]'s, or content that - * will not parse as a profile. A caller gets a profile the group really - * signed or gets nothing. - */ - fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupNostrProfile? { - if (signedEvent.kind != MetadataEvent.KIND) return null - if (!signedEvent.verifies()) return null - - // An empty kind:0 parses to an empty profile rather than to null -- - // wiping a profile is a thing groups are entitled to do, and quartz - // reads it as the blank one it is. Null here is a parse failure. - val metadata = MetadataEvent( - id = signedEvent.id, - pubKey = signedEvent.publicKey, - createdAt = signedEvent.createdAt.epochSeconds, - tags = signedEvent.tags, - content = signedEvent.content, - sig = signedEvent.signature, - ).contactMetaData() ?: return null - - return GroupNostrProfile(signedEvent = signedEvent, metadata = metadata) - } - - /** - * The newest profile [chatRoomId] signed among [signedEvents], or null if - * it signed none. - * - * Newest by the timestamp the group signed at, with the id breaking a tie, - * because kind:0 is replaceable: a group that edits its profile signs a - * second one and the second is the answer. Two devices reading the same - * events in whatever order the queries hand them over have to agree on - * which, and a tie on the second is not rare enough to leave to luck. - * - * Takes the events rather than fetching them so the same question can be - * asked of a query's result or of a flow's emission. - */ - fun newestAmong( - signedEvents: List, - chatRoomId: String, - ): GroupNostrProfile? = - signedEvents - .asSequence() - .mapNotNull { of(it, chatRoomId) } - .maxWithOrNull( - compareBy({ it.signedAt }, { it.signedEvent.id }) - ) - - /** - * The event to ask the group to sign for a profile with these fields. - * - * Built from [latest] where the group has one, so a field this app does not - * offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives - * an edit instead of being dropped by it. That is `updateFromPast`'s whole - * job, and reaching for `createNew` on a group that already has a profile - * would quietly wipe every such field. - * - * Blank means delete, which is `MetadataEvent`'s own rule for these - * arguments: an empty string removes the key and null leaves it alone. The - * screen sends what its fields hold, so clearing one clears it in the - * profile -- which is the only way to *un*-say something a group has said. - * - * [name] is written to both `name` and `display_name`, which are the two - * fields readers pick a name out of and which disagree at their peril. A - * group whose `display_name` was set by some other tool and whose `name` was - * edited here would keep answering to the old one in every client that - * prefers `display_name` -- an edit that visibly does not take. One field on - * the form, both keys in the event. - */ - fun template( - latest: GroupNostrProfile?, - name: String, - about: String, - picture: String, - website: String, - nip05: String, - lud16: String, - ): EventTemplate = latest?.let { - MetadataEvent.updateFromPast( - latest = it.asMetadataEvent(), - name = name, - displayName = name, - about = about, - picture = picture, - website = website, - nip05 = nip05, - lnAddress = lud16, - ) - } ?: MetadataEvent.createNew( - name = name, - displayName = name, - about = about, - picture = picture, - website = website, - nip05 = nip05, - lnAddress = lud16, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt deleted file mode 100644 index adbe327b..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt +++ /dev/null @@ -1,121 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import kotlin.time.Instant - -/** - * A kind:1 the group signed: something it said publicly, as itself. - * - * The room's id is the pubkey it signs as, so a note authored by that key is the - * *group* posting rather than a member posting in the group. Those are different - * things and the room already has the second one -- the transcript. This is the - * first: a statement the group put a quorum's signature to, which any reader can - * check came from the group and not from whoever typed it. - * - * ### Why this is not a `Post` row - * - * `Post` hangs off both `NostrEvent` and `Profile` by foreign key, and a group has - * neither: a group-signed event is not a `NostrEvent` -- see `GroupNostrProfile`, - * which gives the reasoning at length -- and the group has no `Profile` row for the - * same reason. So a group's posts live where the group's other signed statements - * live, and this is the reading of them. - * - * ### Nothing here has reached a relay - * - * The same caveat `GroupNostrProfile` and `GroupRelayList` carry, and it bites - * hardest here: a post is the one kind of statement whose entire purpose is to be - * read by people outside the group. `FrostSigningManager.complete` applies signed - * events locally and puts nothing on the wire, so for now a group's posts are - * visible to its members and to nobody else. The relay lists beside them say where - * they should go; sending them is a job that does not exist yet. - */ -data class GroupPost( - /** The group's statement, whole, with the signature that makes it one. */ - val signedEvent: GroupSignedEvent, -) { - /** The group's nostr identity, which for a derived room is also its id. */ - val publicKey: HexKey get() = signedEvent.publicKey - - /** When the group signed it, which is the order posts are read in. */ - val postedAt: Instant get() = signedEvent.createdAt - - /** What the group said. */ - val content: String get() = signedEvent.content - - /** - * Whether this is the group speaking rather than answering. - * - * A kind:1 with no `e` tag starts a thread; one with them is a reply. Nothing - * here writes a reply yet -- the composer builds a bare note -- so this exists - * to keep an imported or chronicled reply from being drawn as an announcement. - */ - fun isNewThread(): Boolean = asTextNoteEvent().isNewThread() - - /** The event as the group signed it. */ - fun asTextNoteEvent(): TextNoteEvent = signedEvent.toEvent().let { event -> - TextNoteEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig, - ) - } - - companion object { - /** - * The reading of one signed event, or null when it is not one of these. - * - * The same gate the profile and the relay lists use: the wrong kind, or a - * signature that does not check out as [chatRoomId]'s, and a caller gets - * nothing. A kind:1 a member sent into the room is that member's note, not - * the group's, and fails on the author. - */ - fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupPost? { - if (signedEvent.kind != TextNoteEvent.KIND) return null - if (!signedEvent.verifies()) return null - - return GroupPost(signedEvent) - } - - /** - * Every post [chatRoomId] signed among [signedEvents], newest first. - * - * Newest first because that is how a feed is read, and the id breaks a tie - * so two devices holding the same posts show them in the same order. Unlike - * a profile or a relay list these accumulate rather than replace: kind:1 is - * not replaceable, so a second post stands beside the first. - * - * Blank posts are dropped. A signed note with nothing in it renders as an - * empty card that cannot be told from a broken one, and the composer will - * not produce one -- so anything that reaches here empty came from - * somewhere this app does not control. - */ - fun newestFirstAmong( - signedEvents: List, - chatRoomId: String, - ): List = - signedEvents - .mapNotNull { of(it, chatRoomId) } - .filter { it.content.isNotBlank() } - .sortedWith(compareByDescending { it.postedAt }.thenByDescending { - it.signedEvent.id - }) - - /** - * The event to ask the group to sign for a post saying [note]. - * - * A bare kind:1: no reply tags, no mentions, no subject. What the group is - * doing here is speaking, and every tag `TextNoteEvent.build` can add - * describes a relationship to something else -- a thread to reply into, a - * pubkey to notify -- which a group's first way of posting does not have - * and should not guess at. - */ - fun template(note: String): EventTemplate = - TextNoteEvent.build(note = note.trim()) - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt deleted file mode 100644 index 9ddbbfe3..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt +++ /dev/null @@ -1,279 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import com.vitorpamplona.quartz.nip01Core.core.Kind -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo -import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType -import kotlin.time.Instant -import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag as Nip51RelayTag -import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag as Nip17RelayTag - -/** - * Where a group lives on nostr, as the group itself has said. - * - * A Marmot room's id is the pubkey it signs as, so the group is an author like any - * other and the same four relay lists apply to it: where it publishes and is read - * from, where its messages arrive, where it can be searched for, and what it will - * not talk to. Each is a replaceable event of its own kind, so each is agreed -- - * and can be missing -- separately. - * - * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the - * reasons `GroupNostrProfile` gives at length: a group-signed event is not a - * `NostrEvent`, and only an event the room itself authored is the group speaking. - * - * ### Nothing here reaches a relay by being signed - * - * The lists say where the group's work *should* go; sending it there is a separate - * act, taken by a member one event at a time from `BroadcastGroupSignedEventScreen`, - * and the General list is what that screen fills its relay picker from. See - * `GroupNostrProfile`, which carries the same caveat for the same reason. - * - * ### Public tags only, and not by preference - * - * NIP-51 puts a relay list's entries in the encrypted half by default -- a blocked - * list especially, since who you refuse to talk to is nobody's business. A group - * cannot use it. The encryption is NIP-44 to the author's own key, and there is no - * ECDH for a FROST threshold key here: the group can sign and cannot decrypt. So - * every list is written in public tags, which is a real difference from what a - * person's client would write and is worth knowing before blocking anything. - */ -data class GroupRelayList( - val set: GroupRelaySet, - /** - * The group's statement, or null for a set it has never agreed. - * - * Null and an empty [relays] are different states: never said, versus said and - * said nothing. A group that signs an empty list has withdrawn the one it had. - */ - val signedEvent: GroupSignedEvent?, - val relays: List, -) { - /** When the group signed it, or null for a set it has never agreed. */ - val signedAt: Instant? get() = signedEvent?.createdAt - - /** Whether the group has ever put its key to this set. */ - val isAgreed: Boolean get() = signedEvent != null - - companion object { - /** - * The reading of one signed event as [set], or null when it is not one. - * - * The same three refusals as `GroupNostrProfile.of`: the wrong kind, a - * signature that is not [chatRoomId]'s, or tags that carry no relay. - */ - fun of( - signedEvent: GroupSignedEvent, - chatRoomId: String, - set: GroupRelaySet, - ): GroupRelayList? { - if (signedEvent.kind != set.kind) return null - if (!signedEvent.verifies()) return null - - return GroupRelayList( - set = set, - signedEvent = signedEvent, - relays = set.parse(signedEvent.tags), - ) - } - - /** - * The newest list [chatRoomId] signed for [set] among [signedEvents]. - * - * Never null: a set the group has never agreed reads back as an unsigned, - * empty list rather than as an absence, so a caller always has a row per - * set to show and [isAgreed] is what separates the two. - * - * Newest by the group's own timestamp with the id breaking a tie, for the - * reason `GroupNostrProfile.newestAmong` gives: these are replaceable, and - * two devices reading the same events in different orders have to agree. - */ - fun newestAmong( - signedEvents: List, - chatRoomId: String, - set: GroupRelaySet, - ): GroupRelayList = - signedEvents - .asSequence() - .mapNotNull { of(it, chatRoomId, set) } - .maxWithOrNull(compareBy({ it.signedAt }, { it.signedEvent?.id })) - ?: GroupRelayList(set = set, signedEvent = null, relays = emptyList()) - - /** Every set, in the order the editor shows them. */ - fun allAmong( - signedEvents: List, - chatRoomId: String, - ): List = - GroupRelaySet.entries.map { newestAmong(signedEvents, chatRoomId, it) } - } -} - -/** - * One relay in one of a group's lists. - * - * [read] and [write] mean something only in [GroupRelaySet.General], which is the - * one list NIP-65 gives markers to; everywhere else a relay is simply in the list - * and both are true. They are kept on the shared type rather than split into two - * so that the editor has one row shape and one add path. - * - * **Neither is not a state.** NIP-65 writes a bare `r` tag for both, `read` for - * read-only and `write` for write-only, and has nothing at all for a relay that is - * neither -- a relay you will not read from and will not write to is one you have - * removed. The editor enforces that rather than this dropping it silently. - */ -data class GroupRelay( - val url: NormalizedRelayUrl, - val read: Boolean = true, - val write: Boolean = true, -) { - /** The host, for a list that is read rather than copied. */ - fun displayUrl(): String = url.displayUrl() -} - -/** - * The relay lists a group keeps, and what each one is for. - * - * The four a person's client keeps, because a group is an author like any other and - * these are the four questions the network asks about one. - * - * **Key packages (MIP-00, kind 10051) are deliberately not here**, though this app - * writes one for every person. A key package is a device's offer to be added to an - * MLS group, and a group has no device and joins nothing -- so a group advertising - * where its key packages live would be pointing at a place that will always be - * empty, which is worse than saying nothing. See `MarmotKeyPackage`, which is keyed - * by member. - * - * **Relay feeds (kind 10012) are not here either**, for the harder reason: the list - * lives in the encrypted half and a threshold key cannot decrypt. See - * [GroupRelayList]'s note on public tags. - */ -enum class GroupRelaySet( - val kind: Kind, - /** Whether NIP-65's read and write markers apply, which is General alone. */ - val hasReadWriteMarkers: Boolean = false, -) { - /** - * NIP-65: where the group publishes and where a reader should look for it. - * - * The one that matters most and the only one with markers. Everything the group - * signs is addressed to whoever wants to read it, and this is the answer to - * where. - */ - General(AdvertisedRelayListEvent.KIND, hasReadWriteMarkers = true), - - /** NIP-17: where a message addressed to the group's key should be sent. */ - Messages(ChatMessageRelayListEvent.KIND), - - /** NIP-50: the relays the group would have a search of its work run against. */ - Search(SearchRelayListEvent.KIND), - - /** NIP-51: relays the group will not talk to, whatever else says otherwise. */ - Blocked(BlockedRelayListEvent.KIND), - ; - - /** - * What a group with nothing agreed starts the editor at. - * - * The app's own relay, which is where everything else this build publishes and - * reads already goes -- see `Relays.ephemeral`. A group seeded with nothing - * would be a group whose first proposal is an empty list, which says less than - * having never said anything at all. - * - * Blocked is the exception and starts empty on purpose: a default there is a - * refusal to talk to somebody that nobody in the group chose. - */ - fun defaults(): List = when (this) { - Blocked -> emptyList() - else -> listOf(GroupRelay(Relays.ephemeral)) - } - - /** The relays out of a signed event's tags, in the order the group wrote them. */ - fun parse(tags: Array>): List = when (this) { - General -> tags.mapNotNull(AdvertisedRelayInfo::parse).map { - GroupRelay( - url = it.relayUrl, - read = it.type.isRead(), - write = it.type.isWrite(), - ) - } - // Both nip51 lists and NIP-17's use a "relay" tag, and the two RelayTag - // classes that parse it are different classes in different packages. Named - // apart at the import so a reader can see which NIP each line is following. - Messages -> tags.mapNotNull(Nip17RelayTag::parse).map { GroupRelay(it) } - Search, Blocked -> tags.mapNotNull(Nip51RelayTag::parse).map { GroupRelay(it) } - }.distinctBy { it.url } - - /** - * The event to ask the group to sign for this set. - * - * Built from scratch rather than from the group's last one, which is the - * opposite of what `GroupNostrProfile.template` does and for a reason: a - * profile is a bag of independent fields where an unknown one is worth keeping, - * and a relay list is one list where the whole point of signing a new one is to - * replace it. Carrying a tag forward here would make a removal impossible. - * - * A relay that is neither read nor write is dropped rather than written, since - * NIP-65 cannot express one -- the editor does not allow the state, and this is - * the second line of that defence rather than the first. - */ - fun template(relays: List): EventTemplate<*> { - val tags = when (this) { - General -> relays.mapNotNull { relay -> - val type = when { - relay.read && relay.write -> AdvertisedRelayType.BOTH - relay.read -> AdvertisedRelayType.READ - relay.write -> AdvertisedRelayType.WRITE - else -> return@mapNotNull null - } - AdvertisedRelayInfo.assemble(relay.url, type) - } - Messages -> relays.map { Nip17RelayTag.assemble(it.url) } - Search, Blocked -> relays.map { Nip51RelayTag.assemble(it.url) } - } - - // Empty content throughout. For General and Messages that is all the kind - // has; for the two nip51 lists it is where the encrypted half would go, and - // a group has no encrypted half. See [GroupRelayList]. - return EventTemplate( - createdAt = kotlin.time.Clock.System.now().epochSeconds, - kind = kind, - tags = tags.toTypedArray(), - content = "", - ) - } - - companion object { - /** - * The kinds a group's relay lists are written as. - * - * A set rather than a walk of [entries], for `ChatMessage.applyInnerEvent`, - * whose dispatch is one `when` over an event's kind -- and a `when` branch - * has to be a constant expression rather than a predicate over an enum. - */ - val KINDS: Set = entries.map { it.kind }.toSet() - - /** - * [url] as a relay a group could actually be told to use, or null. - * - * Structural only, and deliberately narrow: `wss://` because a group's - * signed list is read by strangers over the open network and `ws://` would - * ask them to fetch it in the clear, and no loopback because a relay only - * this device can reach is not something to put a quorum's signature on. - * Wisp's `RelayConfig.isValidUrl` refuses the same three things. - */ - fun relayUrlOrNull(url: String): NormalizedRelayUrl? { - val trimmed = url.trim() - if (!trimmed.startsWith("wss://", ignoreCase = true)) return null - - return RelayUrlNormalizer.normalizeOrNull(trimmed)?.takeUnless { it.isLocalHost() } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt deleted file mode 100644 index 5543e0aa..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt +++ /dev/null @@ -1,601 +0,0 @@ -package press.mantra.compose.nostr.curated - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Kind -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import kotlin.time.Clock - -/** - * Kinds 31888 and 31890: an entry in a curated list, as somebody suggested it and - * as the curator accepted it. - * - * The two are one shape. A suggestion is a reply to a schema carrying one tag - * per field the schema defines; a canonical entry is the same tags again, signed - * by the curator, with an optional pointer back at the suggestion it came from. - * They are read by one reader and checked by one verifier, and only the kind, - * the signer and the pointer tell them apart -- which is what lets the list - * stand on its own if a suggester deletes their entry. - * - * This is the other half of [CuratedSchemaEvent]: that is what a list *asks* - * for, this is what it *gets*. Ported from the reference module's `verifyEntry` - * rule for rule, so that an entry bitcoin.mov accepts is one this app accepts - * and an entry it refuses is refused here for the same reason. The write side, - * [canonicalTemplate], is the port of its `buildCuratedCanonicalTemplate`, and - * walks the same field list as [read] so the two cannot drift. - * - * ### Rejected, not repaired - * - * The NIP is explicit: a client MUST verify an entry against its schema and - * MUST reject one that does not satisfy it, rather than showing it with the - * offending parts blanked out. Relays carry malformed, partial and hostile - * events from other applications, and a half-parsed entry is worse than a - * missing one. So [suggestion] and [canonical] return the entry whole or return - * null, and [verifySuggestion] and [verifyCanonical] say why. - */ -object CuratedEntryEvent { - /** The kind anyone publishes a suggestion under. */ - const val SUGGESTION_KIND: Kind = CuratedSchemaEvent.SUGGESTION_KIND - - /** The kind the curator republishes an accepted suggestion as. */ - const val CANONICAL_KIND: Kind = CuratedSchemaEvent.CANONICAL_KIND - - private const val TAG_ADDRESS = "a" - private const val TAG_EVENT = "e" - private const val TAG_IDENTIFIER = "d" - private const val TAG_TITLE = "title" - private const val TAG_PUBKEY = "p" - private const val TAG_KIND = "k" - - /** NIP-10's markers: the schema being replied to, and the suggestion being credited. */ - private const val MARKER_ROOT = "root" - private const val MARKER_MENTION = "mention" - - /** What every schema coordinate starts with, which is how a root is told from a source. */ - private val SCHEMA_PREFIX = "${CuratedSchemaEvent.KIND}:" - - private val SUGGESTION_PREFIX = "$SUGGESTION_KIND:" - - /** - * The entry [event] carries, checked against [schema] as a suggestion to the - * list at [coordinate], or null when it is not one a client should show. - */ - fun suggestion(event: Event, schema: CuratedSchema, coordinate: String): CuratedEntry? = - if (verifySuggestion(event, schema, coordinate).isEmpty()) read(event, schema) else null - - /** - * The entry [event] carries, checked against [schema] as the list's own at - * [coordinate], or null. [curator] is the pubkey that signed the schema, and - * the only one this will accept an author of. - */ - fun canonical(event: Event, schema: CuratedSchema, coordinate: String, curator: HexKey): CuratedEntry? = - if (verifyCanonical(event, schema, coordinate, curator).isEmpty()) read(event, schema) else null - - /** - * The entry as the tags say it, whether or not it satisfies the schema. - * - * Split from [suggestion] and [canonical] for the same reason - * [CuratedSchemaEvent.read] is split from its parse: a reader that wants to - * show *why* -- a test, a form -- verifies separately. Everything here is - * keyed by the schema's field names rather than by tag, because a field is - * what a form and a screen know an entry by; the tag is where it was kept. - */ - fun read(event: Event, schema: CuratedSchema): CuratedEntry { - val values = schema.fields - .associate { field -> field.name to valuesOf(field, event.tags, event.content) } - .filterValues { it.isNotEmpty() } - - return CuratedEntry( - kind = event.kind, - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - identifier = first(event.tags, TAG_IDENTIFIER)?.trim() ?: "", - title = first(event.tags, TAG_TITLE)?.trim() ?: "", - values = values, - source = if (event.kind == CANONICAL_KIND) source(event.tags) else null, - ) - } - - /** - * The parts of an entry that need no schema to read: its identifier, its - * title and, for a canonical entry, where it came from. - * - * For a reader that has the event and not the list -- the signing screen, - * the transcript -- and wants to say which entry this is rather than check - * it. [CuratedEntry.values] is empty, since without a schema there are no - * fields to key them by. - */ - fun headline(event: Event): CuratedEntry = read(event, NO_SCHEMA) - - /** A schema with no fields, for [headline]: the two lifted tags are read regardless. */ - private val NO_SCHEMA = CuratedSchema(identifier = "", title = "", name = "", description = "", visibility = null) - - /** - * Every reason [event] is not a suggestion to the list at [coordinate], or - * nothing. - * - * The NIP's seven rules in the NIP's order: the kind is the suggestion kind; - * every required field is present; no field without `repeat` appears twice; - * every value matches its field's type and config; every `require-any` group - * is met; the `a` root names the schema; and the author is somebody the - * schema's visibility lets suggest. - */ - fun verifySuggestion(event: Event, schema: CuratedSchema, coordinate: String): List = - verifyEntry(event, schema, coordinate, expectedKind = schema.suggestionKind) - - /** - * Every reason [event] is not the list's own entry at [coordinate], or nothing. - * - * Everything [verifySuggestion] checks, for kind 31890, and two rules on top. - * The author must be [curator]: curation is the one power a schema does not - * delegate, and `visibility` has no bearing on it. And a source reference, - * when there is one, must be a well-formed suggestion coordinate -- a - * malformed one is worse than none, since it credits the wrong person or - * points at an entry nobody suggested. - */ - fun verifyCanonical( - event: Event, - schema: CuratedSchema, - coordinate: String, - curator: HexKey, - ): List = buildList { - addAll(verifyEntry(event, schema, coordinate, expectedKind = CANONICAL_KIND)) - - if (event.pubKey != curator) { - add(CuratedEntryProblem(FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator)) - } - - val source = firstAddress(event.tags, SUGGESTION_PREFIX) - if (source != null && CuratedCoordinate.parse(source) == null) { - add(CuratedEntryProblem(FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed)) - } - } - - /** - * The unsigned canonical entry for the curator to sign: [values] laid out as - * the schema's tags, rooted at the schema's [coordinate], and pointing back - * at [source] when it came from a suggestion. - * - * The reverse of [read], and the reason a canonical entry and the suggestion - * it came from are one shape: both walk the schema's one field list, so what - * this writes is what [canonical] reads back, field for field. Ported from - * the reference module's `buildCuratedCanonicalTemplate` tag for tag, so - * that an entry this app signs is one bitcoin.mov's list shows. - * - * [values] is keyed by field name the way [CuratedEntry.values] is, derived - * fields included. The reference client derives `d` and `t` for a suggestion - * it is composing; this app composes none, and a suggestion it accepts keeps - * what the suggester's client derived -- the `d` above all, which the NIP - * says a canonical entry SHOULD keep so that re-curating revises the entry - * in place rather than adding a second one. Nothing is clipped or repaired - * on the way through: a value the schema refuses is refused by - * [verifyCanonical] on the result, with the field named, which is what a - * form wants to show. A blank value is not a value and writes nothing, and a - * field without `repeat` writes its first value only. - * - * [relayHint] goes on the root, per NIP-10: where a reader who does not know - * the list can find the schema being replied to. The NIP says it SHOULD be - * the first of the schema's relays, and by default it is. - */ - fun canonicalTemplate( - schema: CuratedSchema, - coordinate: String, - curator: HexKey, - values: Map>, - source: CuratedSuggestionRef? = null, - relayHint: String? = schema.relays.firstOrNull(), - createdAt: Long = Clock.System.now().epochSeconds, - ): EventTemplate { - val (fieldTags, content) = fieldTags(schema, values) - - return EventTemplate( - createdAt = createdAt, - kind = CANONICAL_KIND, - tags = (fieldTags + replyTags(coordinate, curator, relayHint) + sourceTags(source)).toTypedArray(), - content = content, - ) - } - - /** - * [template] as an event the verifiers can read: the kind, the tags, the - * content and [pubKey], which is everything they look at, over a blank id - * and signature, which they do not. For checking what is about to be - * proposed before a quorum is spent on it. - */ - fun preview(template: EventTemplate<*>, pubKey: HexKey): Event = Event( - id = "", - pubKey = pubKey, - createdAt = template.createdAt, - kind = template.kind, - tags = template.tags, - content = template.content, - sig = "", - ) - - /** - * The tags that make an entry a reply to its schema, and nothing else. - * - * The root is the schema's coordinate rather than an event id, because the - * kind is addressable: revising the schema mints a new id and keeps the - * coordinate, and an entry pinned to an id would be orphaned by the first - * edit. `p` and `k` are the reply courtesies the NIP asks for and nothing - * verifies; `a` is what is verified and what relays are queried on. - */ - fun replyTags(coordinate: String, curator: HexKey, relayHint: String?): List> = listOf( - arrayOf(TAG_ADDRESS, coordinate, relayHint ?: "", MARKER_ROOT), - arrayOf(TAG_PUBKEY, curator), - arrayOf(TAG_KIND, CuratedSchemaEvent.KIND.toString()), - ) - - /** - * The values as the schema's tags: one per value at the field's tag, with - * its marker as the third element when it has one, or the body for the - * field that writes there. - */ - private fun fieldTags(schema: CuratedSchema, values: Map>): Pair>, String> { - val tags = mutableListOf>() - var content = "" - - for (field in schema.fields) { - val tag = field.tag() - val marker = field.config.marker - - for (value in values[field.name].orEmpty()) { - val trimmed = value.trim() - if (trimmed.isEmpty()) continue - - if (tag == CuratedFieldConfig.CONTENT_TAG) { - content = trimmed - } else { - tags.add(if (marker != null) arrayOf(tag, trimmed, marker) else arrayOf(tag, trimmed)) - } - - if (field.config.repeat != true) break - } - } - - return tags to content - } - - /** - * Both halves of the pointer back: the coordinate, which follows the - * suggester's later edits, and the id, which pins what the curator actually - * read. They answer different questions, which is why the NIP has both. The - * `p` credits the suggester. - */ - private fun sourceTags(source: CuratedSuggestionRef?): List> { - if (source == null) return emptyList() - - return buildList { - add(arrayOf(TAG_ADDRESS, source.coordinate, "", MARKER_MENTION)) - source.id?.let { add(arrayOf(TAG_EVENT, it, "", MARKER_MENTION)) } - add(arrayOf(TAG_PUBKEY, source.pubKey)) - } - } - - /** - * One value against its field's type and config, or the first thing wrong - * with it. - * - * `max` is a bound on numeric types and a length on everything else, and a - * numeric field returns before the length check so the two never meet. - * A pattern the platform cannot compile counts as not matched: the schema - * asked for a check this reader cannot make, and an entry it cannot check - * is one it cannot vouch for. - */ - fun checkValue(field: CuratedField, value: String): CuratedEntryProblem.Reason? { - val trimmed = value.trim() - val config = field.config - - if (trimmed.isEmpty()) return CuratedEntryProblem.Reason.Empty - - when (field.type) { - CuratedFieldType.Text, CuratedFieldType.LongText -> Unit - - CuratedFieldType.Token -> - if (trimmed.any { it.isWhitespace() }) return CuratedEntryProblem.Reason.NotOneWord - - CuratedFieldType.Url -> - if (!isSafeUrl(trimmed, httpsOnly = config.https == true)) { - return if (config.https == true) { - CuratedEntryProblem.Reason.NotHttps - } else { - CuratedEntryProblem.Reason.NotAUrl - } - } - - CuratedFieldType.Image -> - if (!isSafeUrl(trimmed, httpsOnly = true)) return CuratedEntryProblem.Reason.NotHttps - - CuratedFieldType.Enum -> - if (config.options?.contains(trimmed) != true) return CuratedEntryProblem.Reason.NotAnOption - - CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> { - val number = trimmed.toWholeNumberOrNull() ?: return CuratedEntryProblem.Reason.NotAWholeNumber - if (field.type == CuratedFieldType.Duration && number <= 0) return CuratedEntryProblem.Reason.NotPositive - config.min?.let { if (number < it) return CuratedEntryProblem.Reason.BelowMinimum } - config.max?.let { if (number > it) return CuratedEntryProblem.Reason.AboveMaximum } - // A numeric `max` is a bound, not a length cap. - return null - } - } - - config.max?.let { if (trimmed.length > it) return CuratedEntryProblem.Reason.TooLong } - - config.pattern?.let { pattern -> - val whole = runCatching { Regex("^(?:$pattern)$") }.getOrNull() - if (whole == null || !whole.matches(trimmed)) return CuratedEntryProblem.Reason.PatternMismatch - } - - return null - } - - /** - * Every value [field] holds in an event: its tags, or the content body. - * - * A field with a marker matches only tags carrying that marker in third - * position; one without matches every tag of its name, whatever the third - * element says. Blank values are not values. - */ - fun valuesOf(field: CuratedField, tags: Array>, content: String): List { - val tag = field.tag() - if (tag == CuratedFieldConfig.CONTENT_TAG) { - return content.trim().takeIf { it.isNotEmpty() }?.let { listOf(it) } ?: emptyList() - } - - val marker = field.config.marker - return tags - .filter { it.size > 1 && it[0] == tag && it[1].isNotBlank() } - .filter { marker == null || it.getOrNull(2) == marker } - .map { it[1] } - } - - /** - * Where a canonical entry says it came from, or null for one nobody suggested. - * - * The coordinate is the `a` tag with the suggestion kind's prefix, which is - * what tells it from the schema root; the id is the first `e` tag, pinning - * the exact version the curator reviewed, and may be absent. - */ - fun source(tags: Array>): CuratedSuggestionRef? { - val address = firstAddress(tags, SUGGESTION_PREFIX) ?: return null - val coordinate = CuratedCoordinate.parse(address) ?: return null - - return CuratedSuggestionRef( - id = first(tags, TAG_EVENT)?.trim()?.ifEmpty { null }, - coordinate = address, - pubKey = coordinate.pubKey, - ) - } - - /** An http or https URL with a host, or with [httpsOnly] an https one. Nothing else reaches a link. */ - fun isSafeUrl(value: String, httpsOnly: Boolean = false): Boolean = - if (httpsOnly) CuratedSchemaEvent.isHttpsUrl(value) else HTTP_URL.matches(value.trim()) - - private val HTTP_URL = Regex("^https?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE) - - /** The problems a suggestion and a canonical entry share. */ - private fun verifyEntry( - event: Event, - schema: CuratedSchema, - coordinate: String, - expectedKind: Kind, - ): List = buildList { - if (event.kind != expectedKind) { - add(CuratedEntryProblem(FIELD_KIND, CuratedEntryProblem.Reason.WrongKind)) - } - - // The root is required whenever the schema has a coordinate to reply to, - // which here is always: a group's schema is signed before anything can - // reply to it. Only schema coordinates count; an entry carries `a` tags - // for other reasons too, the source pointer among them. - val roots = event.tags - .filter { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(SCHEMA_PREFIX) } - .map { it[1] } - if (roots.isEmpty()) { - add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply)) - } else if (coordinate !in roots) { - add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere)) - } - - for (field in schema.fields) { - val values = valuesOf(field, event.tags, event.content) - - if (values.isEmpty()) { - if (field.isRequired) add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Missing)) - continue - } - - if (values.size > 1 && field.config.repeat != true) { - add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Repeated)) - } - - // One problem per field is enough. - values.firstNotNullOfOrNull { checkValue(field, it) } - ?.let { add(CuratedEntryProblem(field.name, it)) } - } - - for (group in schema.requireAny) { - val satisfied = group.any { name -> - schema.fields.firstOrNull { it.name == name } - ?.let { valuesOf(it, event.tags, event.content).isNotEmpty() } - ?: false - } - if (!satisfied) { - add(CuratedEntryProblem(group.first(), CuratedEntryProblem.Reason.RequireAnyUnmet)) - } - } - - if (!schema.canSuggest(event.pubKey, coordinate)) { - add(CuratedEntryProblem(FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)) - } - } - - /** - * Whether [pubKey] may suggest to this list, at [coordinate]. - * - * Everyone on a public list; the curator and the `p` tags on the other two. - * The curator is read off the coordinate rather than passed, because a - * [CuratedSchema] does not know who signed it and the coordinate does. - */ - private fun CuratedSchema.canSuggest(pubKey: HexKey, coordinate: String): Boolean { - if (visibility == CuratedVisibility.Public) return true - if (CuratedCoordinate.parse(coordinate)?.pubKey == pubKey) return true - return pubKey in suggesters - } - - private fun first(tags: Array>, name: String): String? = - tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1) - - private fun firstAddress(tags: Array>, prefix: String): String? = - tags.firstOrNull { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(prefix) }?.get(1) - - /** An optionally signed run of digits and nothing else, as the reference reads one. */ - private fun String.toWholeNumberOrNull(): Long? = - takeIf { WHOLE_NUMBER.matches(it) }?.toLongOrNull() - - private val WHOLE_NUMBER = Regex("^-?\\d+$") - - /** The names problems are filed under when they are about the entry rather than a field. */ - const val FIELD_KIND = "kind" - const val FIELD_SCHEMA = "schema" - const val FIELD_VISIBILITY = "visibility" - const val FIELD_CURATOR = "curator" - const val FIELD_SOURCE = "source" -} - -/** - * One entry in a curated list, read off a kind 31888 or 31890 event. - * - * [values] is keyed by the schema's field names, each holding every value the - * entry carries for that field in tag order -- more than one only for a field - * the schema lets repeat. A field the entry has nothing for is absent rather - * than empty. [identifier] and [title] are also in there under whatever the - * schema calls those fields; they are lifted out because every list has them - * and every screen wants them. - */ -data class CuratedEntry( - val kind: Kind, - val id: HexKey, - val pubKey: HexKey, - /** Unix seconds, as the event carries it. */ - val createdAt: Long, - /** The `d` tag, which with [kind] and [pubKey] is the entry's coordinate. */ - val identifier: String, - val title: String, - val values: Map>, - /** For a canonical entry, the suggestion it came from, or null when nobody suggested it. */ - val source: CuratedSuggestionRef? = null, -) { - val isCanonical: Boolean get() = kind == CuratedEntryEvent.CANONICAL_KIND - - /** `kind:pubkey:d`. Two events sharing one are versions of one entry. */ - fun coordinate(): String = CuratedCoordinate(kind, pubKey, identifier).toValue() - - /** The first value of [fieldName], or null when the entry has none. */ - fun value(fieldName: String): String? = values[fieldName]?.firstOrNull() -} - -/** Where a canonical entry came from: the suggestion the curator accepted. */ -data class CuratedSuggestionRef( - /** The exact version curated, or null when the curator did not pin one. */ - val id: HexKey?, - /** `31888::`, which follows the suggester's later edits. */ - val coordinate: String, - /** The suggester, credited. */ - val pubKey: HexKey, -) { - companion object { - /** - * The pointer a canonical entry made from [entry] carries: its id, its - * coordinate and its author. Only a suggestion can be pointed at -- a - * canonical entry is the list's own word, and the list did not suggest - * anything to itself. - */ - fun of(entry: CuratedEntry): CuratedSuggestionRef { - require(!entry.isCanonical) { "A canonical entry is not the source of another" } - - return CuratedSuggestionRef( - id = entry.id, - coordinate = entry.coordinate(), - pubKey = entry.pubKey, - ) - } - } -} - -/** - * One reason an entry does not satisfy its schema: which field, and what about - * it. [field] is the field's name, or one of the [CuratedEntryEvent] `FIELD_` - * names for a rule about the entry as a whole; a `require-any` group is filed - * under its first field, so a form can show it inline. - */ -data class CuratedEntryProblem(val field: String, val reason: Reason) { - enum class Reason { - /** Not the kind the schema said suggestions come as, or not the canonical kind. */ - WrongKind, - - /** No `a` root naming any schema. */ - NotAReply, - - /** An `a` root naming some other schema. */ - RepliesElsewhere, - - /** A required field with no value. */ - Missing, - - /** A field without `repeat` with more than one. */ - Repeated, - - Empty, - NotOneWord, - NotAUrl, - NotHttps, - NotAnOption, - NotAWholeNumber, - NotPositive, - BelowMinimum, - AboveMaximum, - TooLong, - PatternMismatch, - - /** None of a `require-any` group's fields is present. */ - RequireAnyUnmet, - - /** The author may not suggest to a closed or private list. */ - NotPermitted, - - /** A canonical entry signed by somebody other than the schema's author. */ - NotTheCurator, - - /** A source pointer that is not a suggestion coordinate. */ - SourceMalformed, - } -} - -/** - * An addressable coordinate, `::`, split the one way the NIP - * allows: on the first two colons only. A `d` may itself contain colons -- - * `imdb:tt2821314` does -- so splitting on every one mangles the identifier and - * miscounts the parts. - */ -data class CuratedCoordinate(val kind: Kind, val pubKey: HexKey, val identifier: String) { - fun toValue(): String = "$kind:$pubKey:$identifier" - - companion object { - private val SHAPE = Regex("^(\\d{1,5}):([0-9a-fA-F]{64}):(.+)$") - - /** The three parts, or null for anything that is not a coordinate with a non-empty `d`. */ - fun parse(value: String): CuratedCoordinate? { - val match = SHAPE.matchEntire(value.trim()) ?: return null - val (kind, pubKey, identifier) = match.destructured - - return CuratedCoordinate( - kind = kind.toInt(), - pubKey = pubKey.lowercase(), - identifier = identifier, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt deleted file mode 100644 index cc6e7d6d..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt +++ /dev/null @@ -1,475 +0,0 @@ -package press.mantra.compose.nostr.curated - -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Kind -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull -import kotlinx.serialization.json.put -import press.mantra.compose.network.serialization.CommonJson - -/** - * The definition of a curated list: what an entry may contain, who may suggest - * one, and whose list it is. - * - * This is the document the other two curated kinds are checked against. A - * suggestion (kind 31888) is a reply to it that has to satisfy its fields; a - * canonical entry (kind 31890) is a suggestion its curator republished under - * their own key. The list is the set of canonical entries and the suggestions - * are its queue -- and this is the form both are filled in against, published - * so that another client can render the same form without shipping this code. - * - * The curator's pubkey is the namespace. `31889::` names - * one schema and no other, so nothing global is claimed and nothing can be - * squatted. Here the curator is a group: the schema is signed by the room's own - * key, like its profile and its posts, and it is the group -- by quorum -- that - * accepts a suggestion into the list. - * - * The protocol is written up in `bitcoin.mov/docs/NIP.md`, and the shape here - * follows its reference module tag for tag so that a schema this app signs is - * one that site's form can be driven by, and one it publishes reads here. - * - * ### What is checked, and where - * - * [problems] is the list of reasons a schema is unusable, and it is the same - * list whether the schema was typed into the editor or read off a relay: the - * NIP's rejection rules, no more. A reader rejects rather than repairs -- - * relays carry malformed and hostile events, and a half-parsed form is worse - * than a missing one -- and the editor refuses to propose what a reader would - * refuse to show. - * - * [normalized] is the one repair the NIP requires. Every list has a field - * writing to `d` and one writing to `title`, both required, and a schema that - * omits or relaxes either gets them put back -- so no schema, wherever it came - * from, can talk a client into accepting untitled or unaddressable entries. - */ -data class CuratedSchema( - /** The `d` tag: the schema's identifier, and the last part of its coordinate. */ - val identifier: String, - /** Labels the event -- "bitcoin.mov suggestion". */ - val title: String, - /** The list's identity, what people call it -- "bitcoin.mov". */ - val name: String, - /** What the list is for. Mirrored into the event's content for generic clients. */ - val description: String, - /** - * Who may suggest, or null when the schema does not say. - * - * Null is never defaulted away: a list that does not say who may suggest to - * it is not one a client should act on, so an absent or unrecognised - * visibility is a [CuratedSchemaProblem.VisibilityMissing] rather than a - * public list by accident. The editor always has one; only a schema read off - * a relay can be missing it. - */ - val visibility: CuratedVisibility?, - /** An https image for the list, or null. */ - val picture: String? = null, - /** - * A hostname the list publishes under, or null. Shown in place of [name] - * wherever the list is named, which is why it is a claim worth being careful - * with: anyone can put any domain in a tag. See [displayName]. - */ - val domain: String? = null, - /** The kind suggestions are published under. There is only one. */ - val suggestionKind: Kind = CuratedSchemaEvent.SUGGESTION_KIND, - val fields: List = emptyList(), - /** - * Groups of field names of which at least one must be present on an entry. - * - * How a list says "a watch link or an IMDb link" without making either one - * required. A group of fewer than two names says nothing and is dropped. - */ - val requireAny: List> = emptyList(), - /** - * Pubkeys allowed to suggest on a closed or private list, besides the - * curator. The `p` tags. Meaningless on a public list, where anyone may. - */ - val suggesters: List = emptyList(), - /** - * Where the list lives: the relays suggestions and canonical entries are - * published to and read from. Signed into the event so a client that finds - * the schema anywhere knows where a reply belongs and cannot be sent - * elsewhere by an unsigned config file. - */ - val relays: List = emptyList(), -) { - /** - * What to call the list on screen: the domain when it has one, else the - * name. The domain is the stronger identity because it is the one part of - * this that can be checked against the outside world, NIP-05 style -- and - * nothing here does check it, so it is a label rather than a badge. - */ - fun displayName(): String = domain ?: name - - /** Fields a form should prompt for. Derived ones are filled in by the client. */ - fun formFields(): List = fields.filterNot { it.config.derived == true } - - /** - * The same schema with the two mandatory fields forced into place. - * - * A field writing to `d` and one writing to `title`, both required, in that - * order at the front when they were missing. A schema that already has them - * is unchanged apart from the requirement, so running this twice is running - * it once. - */ - fun normalized(): CuratedSchema { - val fields = fields.toMutableList() - - for ((tag, fallback) in CuratedField.MANDATORY) { - val index = fields.indexOfFirst { it.tag() == tag } - if (index == -1) { - fields.add(0, fallback) - } else if (!fields[index].isRequired) { - fields[index] = fields[index].copy(isRequired = true) - } - } - - return copy(fields = fields) - } - - /** - * Every reason this is not a usable schema, or nothing. - * - * The NIP's rejection rules and only those: a client MUST refuse a schema - * missing its identifier, title, name, description or visibility, one whose - * visibility is not one of the three words, one with no fields, and one - * whose picture is not https, whose domain is not a hostname, or whose relay - * is not a relay. The length caps are the reference module's, so a schema - * this app signs is one that site accepts. - * - * Checked on the schema as written, before [normalized] -- "no fields" is - * about what the publisher said, not about what a reader would put back. - */ - fun problems(): List = buildList { - fun text(value: String, cap: Int, missing: CuratedSchemaProblem, tooLong: CuratedSchemaProblem) { - if (value.isBlank()) add(missing) else if (value.length > cap) add(tooLong) - } - - text(identifier, CuratedSchemaEvent.MAX_IDENTIFIER, CuratedSchemaProblem.IdentifierMissing, CuratedSchemaProblem.IdentifierTooLong) - text(title, CuratedSchemaEvent.MAX_TITLE, CuratedSchemaProblem.TitleMissing, CuratedSchemaProblem.TitleTooLong) - text(name, CuratedSchemaEvent.MAX_NAME, CuratedSchemaProblem.NameMissing, CuratedSchemaProblem.NameTooLong) - text(description, CuratedSchemaEvent.MAX_DESCRIPTION, CuratedSchemaProblem.DescriptionMissing, CuratedSchemaProblem.DescriptionTooLong) - - if (visibility == null) add(CuratedSchemaProblem.VisibilityMissing) - - picture?.let { - if (!CuratedSchemaEvent.isHttpsUrl(it) || it.length > CuratedSchemaEvent.MAX_URL) { - add(CuratedSchemaProblem.PictureNotHttps) - } - } - - domain?.let { - if (!CuratedSchemaEvent.isDomain(it)) add(CuratedSchemaProblem.DomainInvalid) - } - - if (fields.isEmpty()) add(CuratedSchemaProblem.NoFields) - - if (relays.any { !CuratedSchemaEvent.isRelayUrl(it) }) add(CuratedSchemaProblem.RelayInvalid) - } - - fun isUsable(): Boolean = problems().isEmpty() -} - -/** A reason a [CuratedSchema] cannot be published or believed. See [CuratedSchema.problems]. */ -enum class CuratedSchemaProblem { - IdentifierMissing, - IdentifierTooLong, - TitleMissing, - TitleTooLong, - NameMissing, - NameTooLong, - DescriptionMissing, - DescriptionTooLong, - VisibilityMissing, - PictureNotHttps, - DomainInvalid, - NoFields, - RelayInvalid, -} - -/** - * Who may suggest to a list, and who it is meant for. - * - * Suggesting only. Curation is never delegated: a public list still has exactly - * one curator, and the `p` tags on a closed list name extra *suggesters*. - * - * Relays are open, so [Private] is a convention clients follow rather than an - * encryption anybody enforces. Nothing secret belongs in a private list. - */ -enum class CuratedVisibility(val value: String) { - /** Anyone may suggest; anyone may read. */ - Public("public"), - - /** The curator and the `p` tags may suggest; anyone may read. */ - Closed("closed"), - - /** The curator and the `p` tags may suggest; clients should show it only to them. */ - Private("private"), - ; - - companion object { - /** Case-insensitive, trimmed, and null for anything but the three words. */ - fun parse(value: String): CuratedVisibility? = - value.trim().lowercase().let { word -> entries.firstOrNull { it.value == word } } - } -} - -/** - * What kind of value a field takes. One validation rule each, in the NIP's - * table; a form picks its input from this. - * - * [isNumeric] decides what `max` means: a maximum *value* for these, a maximum - * *length* for everything else. The double duty is safe because a field is one - * or the other, never both. - */ -enum class CuratedFieldType(val value: String, val isNumeric: Boolean = false) { - /** Single-line string, length-capped. */ - Text("text"), - - /** Multi-line string. */ - LongText("longtext"), - - /** One word, no whitespace -- `imdb:tt2821314`. */ - Token("token"), - - /** An http(s) URL; `https` in the config narrows it. */ - Url("url"), - - /** A URL that must be https, since browsers block insecure images. */ - Image("image"), - - /** One of the config's `options`. */ - Enum("enum"), - - /** An integer year, bounded by `min` and `max`. */ - Year("year", isNumeric = true), - - /** A positive integer number of seconds, bounded by `min` and `max`. */ - Duration("duration", isNumeric = true), - - /** An integer, bounded by `min` and `max`. */ - Number("number", isNumeric = true), - ; - - companion object { - fun parse(value: String): CuratedFieldType? = entries.firstOrNull { it.value == value } - } -} - -/** - * Everything about a field beyond its name, type, optionality, placeholder and - * label -- the JSON object in the seventh position of a `field` tag. - * - * Null throughout means "not said", and a key that is not said is not written, - * so a config with nothing in it is `{}`. [others] carries keys this app does - * not know, so editing a field another client wrote does not silently strip - * what that client meant by it. - */ -data class CuratedFieldConfig( - /** The tag the field writes to. Defaults to the field's name; [CONTENT_TAG] means the body. */ - val tag: String? = null, - /** A marker written as the tag's third element -- `["r", url, "watch"]`. */ - val marker: String? = null, - /** Maximum characters for text-ish types, maximum value for numeric ones. */ - val max: Long? = null, - /** Minimum value. Numeric types only. */ - val min: Long? = null, - /** The allowed values of an enum. */ - val options: List? = null, - /** Require https on a url. */ - val https: Boolean? = null, - /** The tag may appear more than once. */ - val repeat: Boolean? = null, - /** A regular expression the trimmed value must match end to end. */ - val pattern: String? = null, - /** The client fills the value in and must not prompt for it. */ - val derived: Boolean? = null, - /** Helper text under the input. */ - val hint: String? = null, - /** Keys this app does not know, kept as they came. */ - val others: Map = emptyMap(), -) { - /** The blob as it goes into the tag. Known keys first, in the NIP's order, then [others]. */ - fun toJson(): String = buildJsonObject { - tag?.let { put(KEY_TAG, it) } - marker?.let { put(KEY_MARKER, it) } - max?.let { put(KEY_MAX, it) } - min?.let { put(KEY_MIN, it) } - options?.let { put(KEY_OPTIONS, JsonArray(it.map(::JsonPrimitive))) } - https?.let { put(KEY_HTTPS, it) } - repeat?.let { put(KEY_REPEAT, it) } - pattern?.let { put(KEY_PATTERN, it) } - derived?.let { put(KEY_DERIVED, it) } - hint?.let { put(KEY_HINT, it) } - others.forEach { (key, value) -> put(key, value) } - }.toString() - - companion object { - /** The `tag` value meaning the event's content rather than a tag. */ - const val CONTENT_TAG = "content" - - private const val KEY_TAG = "tag" - private const val KEY_MARKER = "marker" - private const val KEY_MAX = "max" - private const val KEY_MIN = "min" - private const val KEY_OPTIONS = "options" - private const val KEY_HTTPS = "https" - private const val KEY_REPEAT = "repeat" - private const val KEY_PATTERN = "pattern" - private const val KEY_DERIVED = "derived" - private const val KEY_HINT = "hint" - - private val KNOWN_KEYS = setOf( - KEY_TAG, KEY_MARKER, KEY_MAX, KEY_MIN, KEY_OPTIONS, - KEY_HTTPS, KEY_REPEAT, KEY_PATTERN, KEY_DERIVED, KEY_HINT, - ) - - /** - * The blob read back, leniently. - * - * A malformed blob costs the field its constraints and not its life: the - * NIP says a bad config must not invalidate the field, so anything that - * is not a JSON object reads as no config at all, and a known key of the - * wrong type is dropped rather than kept as something it is not. - */ - fun fromJson(json: String?): CuratedFieldConfig { - if (json.isNullOrBlank()) return CuratedFieldConfig() - - val obj = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() as? JsonObject - ?: return CuratedFieldConfig() - - fun string(key: String): String? = - (obj[key] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull - - fun long(key: String): Long? = - (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull - - fun boolean(key: String): Boolean? = - (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull - - return CuratedFieldConfig( - tag = string(KEY_TAG), - marker = string(KEY_MARKER), - max = long(KEY_MAX), - min = long(KEY_MIN), - options = (obj[KEY_OPTIONS] as? JsonArray) - ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.contentOrNull }, - https = boolean(KEY_HTTPS), - repeat = boolean(KEY_REPEAT), - pattern = string(KEY_PATTERN), - derived = boolean(KEY_DERIVED), - hint = string(KEY_HINT), - others = obj.filterKeys { it !in KNOWN_KEYS }, - ) - } - } -} - -/** - * One field an entry may -- or must -- carry. - * - * ``` - * ["field", name, type, "required" | "optional", placeholder, label, config] - * ``` - * - * The four things a form needs are positional and everything else is the JSON - * blob in the last position, which is [CuratedFieldConfig]. - */ -data class CuratedField( - /** Stable name; the key a form collects the value under. */ - val name: String, - val type: CuratedFieldType, - val isRequired: Boolean, - /** An example value shown in an empty input. May be empty. */ - val placeholder: String = "", - /** The human label for the input. Empty means [name]. */ - val label: String = "", - val config: CuratedFieldConfig = CuratedFieldConfig(), -) { - /** The tag this field writes to; [CuratedFieldConfig.CONTENT_TAG] means the body. */ - fun tag(): String = config.tag ?: name - - fun labelOrName(): String = label.ifBlank { name } - - fun toTagArray(): Array = arrayOf( - TAG_NAME, - name, - type.value, - if (isRequired) REQUIRED else OPTIONAL, - placeholder, - label, - config.toJson(), - ) - - companion object { - const val TAG_NAME = "field" - - private const val REQUIRED = "required" - private const val OPTIONAL = "optional" - - /** - * The tag read back, or null for one that is not a field. - * - * Null for a blank name or an unknown type, which are the two things a - * form cannot do anything with. Everything after them is optional: a - * missing placeholder is empty, a missing label falls back to the name, - * and a missing or malformed config is no config -- see - * [CuratedFieldConfig.fromJson]. - */ - fun parse(tag: Array): CuratedField? { - if (tag.size < 3 || tag[0] != TAG_NAME) return null - - val name = tag[1].trim() - if (name.isEmpty()) return null - - val type = CuratedFieldType.parse(tag[2]) ?: return null - - return CuratedField( - name = name, - type = type, - isRequired = tag.getOrNull(3) == REQUIRED, - placeholder = tag.getOrNull(4) ?: "", - label = tag.getOrNull(5) ?: "", - config = CuratedFieldConfig.fromJson(tag.getOrNull(6)), - ) - } - - /** The `title` field a schema gets when it left its own out. */ - val TITLE = CuratedField( - name = "title", - type = CuratedFieldType.Text, - isRequired = true, - label = "Title", - config = CuratedFieldConfig(max = 200), - ) - - /** - * The `d` field a schema gets when it left its own out. Derived, so a - * form never asks for it: the client works an identifier out of the - * entry itself. - */ - val IDENTIFIER = CuratedField( - name = "identifier", - type = CuratedFieldType.Token, - isRequired = true, - label = "Identifier", - config = CuratedFieldConfig(tag = "d", max = 80, derived = true), - ) - - /** - * The two fields every list has, keyed by the tag each writes to and in - * the order [CuratedSchema.normalized] puts them back -- title first, so - * that `d` ends up in front of it. - */ - val MANDATORY: List> = listOf( - "title" to TITLE, - "d" to IDENTIFIER, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt deleted file mode 100644 index 37570a6e..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt +++ /dev/null @@ -1,206 +0,0 @@ -package press.mantra.compose.nostr.curated - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Kind -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import kotlin.time.Clock - -/** - * Kind 31889: a curated schema event, and the two kinds it governs. - * - * ``` - * 31889 curated schema the definition of a list, by its curator - * 31888 curated suggestion an entry anyone proposes, in reply to the schema - * 31890 curated canonical a suggestion the curator republished as the list's own - * ``` - * - * All three are addressable, so `kind:pubkey:d` names one entry and republishing - * with the same `d` replaces it. That is what makes a schema revisable: editing - * one signs a new event under the same coordinate, and every suggestion that - * replied to the coordinate -- rather than to an event id -- stays attached. - * - * This object is the wire shape: tags in, tags out. [CuratedSchema] is what the - * tags mean, and `GroupCuratedSchema` is a group's own signed one. - */ -object CuratedSchemaEvent { - const val KIND: Kind = 31889 - - /** The kind a schema's suggestions are published under -- its `k` tag. */ - const val SUGGESTION_KIND: Kind = 31888 - - /** The kind the curator republishes an accepted suggestion as. */ - const val CANONICAL_KIND: Kind = 31890 - - /** Length caps on the identity tags, the reference module's. */ - const val MAX_IDENTIFIER = 100 - const val MAX_TITLE = 200 - const val MAX_NAME = 100 - const val MAX_DESCRIPTION = 500 - const val MAX_URL = 500 - const val MAX_DOMAIN = 253 - - private const val TAG_IDENTIFIER = "d" - private const val TAG_TITLE = "title" - private const val TAG_NAME = "name" - private const val TAG_DESCRIPTION = "description" - private const val TAG_KIND = "k" - private const val TAG_VISIBILITY = "visibility" - private const val TAG_PICTURE = "picture" - private const val TAG_DOMAIN = "domain" - private const val TAG_REQUIRE_ANY = "require-any" - private const val TAG_SUGGESTER = "p" - private const val TAG_RELAY = "relay" - - /** The addressable coordinate: what a suggestion's `a` root names. */ - fun coordinate(pubkey: HexKey, identifier: String): String = "$KIND:$pubkey:$identifier" - - /** - * The schema an event declares, or null when the event is not a usable one. - * - * Defensive in the way a relay demands. The wrong kind is null. Any of - * [CuratedSchema.problems] is null -- a schema that would be rejected on - * publish is rejected on read, and for the same reasons. A `field` tag that - * will not parse is dropped and the rest are kept, which is the one place - * the NIP asks for tolerance; a `require-any` naming fewer than two fields - * says nothing and goes the same way. - * - * What comes back is [CuratedSchema.normalized]: the two mandatory fields - * forced in, whatever the event said. - */ - fun parse(event: Event): CuratedSchema? { - if (event.kind != KIND) return null - - return read(event.tags, event.content).takeIf { it.isUsable() }?.normalized() - } - - /** - * The tags as a schema, whether or not they make a usable one. - * - * Split from [parse] so that a reader wanting the reasons -- the editor, - * a test -- can ask [CuratedSchema.problems] of the result. The identity - * values are trimmed, and the description falls back to the content the way - * generic clients read it: the tag is authoritative and the content is the - * mirror. - */ - fun read(tags: Array>, content: String): CuratedSchema { - fun first(name: String): String? = - tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1) - - fun all(name: String): List = - tags.filter { it.size > 1 && it[0] == name }.map { it[1] } - - return CuratedSchema( - identifier = first(TAG_IDENTIFIER)?.trim() ?: "", - title = first(TAG_TITLE)?.trim() ?: "", - name = first(TAG_NAME)?.trim() ?: "", - description = (first(TAG_DESCRIPTION)?.trim()?.ifEmpty { null } ?: content).trim(), - // Null for an absent or unrecognised word, and null stays null: see - // the field's own note on why it is never defaulted. - visibility = first(TAG_VISIBILITY)?.let(CuratedVisibility::parse), - picture = first(TAG_PICTURE)?.trim()?.ifEmpty { null }, - domain = first(TAG_DOMAIN)?.trim()?.ifEmpty { null }?.let(::normalizeDomain), - suggestionKind = first(TAG_KIND)?.trim()?.toIntOrNull() ?: SUGGESTION_KIND, - fields = tags.mapNotNull(CuratedField::parse), - requireAny = tags - .filter { it.isNotEmpty() && it[0] == TAG_REQUIRE_ANY } - .map { group -> group.drop(1).map { it.trim() }.filter { it.isNotEmpty() } } - .filter { it.size > 1 }, - // Only what is a pubkey. A `p` tag holding anything else names nobody, - // and keeping it would have the editor render it as an npub it is not. - suggesters = all(TAG_SUGGESTER).map { it.trim().lowercase() }.filter(::isPubkey).distinct(), - relays = all(TAG_RELAY).map { it.trim() }.distinct(), - ) - } - - /** - * The unsigned event that publishes [schema], for the group to sign. - * - * Built from the schema alone rather than from the group's last event, the - * way a relay list is and for the same reason: a schema is one document - * whose whole point in being re-signed is to replace the last one, and - * carrying an old tag forward would make removing a field impossible. - * - * The content mirrors the description so that a client reading content - * rather than tags still shows something; the tag is the authoritative one. - */ - fun template( - schema: CuratedSchema, - createdAt: Long = Clock.System.now().epochSeconds, - ): EventTemplate { - val normalized = schema.normalized() - - val tags = buildList { - add(arrayOf(TAG_IDENTIFIER, normalized.identifier)) - add(arrayOf(TAG_TITLE, normalized.title)) - add(arrayOf(TAG_NAME, normalized.name)) - add(arrayOf(TAG_DESCRIPTION, normalized.description)) - add(arrayOf(TAG_KIND, normalized.suggestionKind.toString())) - // Absent rather than invented when the schema has none. Every reader, - // this one included, then refuses the event -- which is the right - // outcome for a schema that does not say who may suggest to it, and - // one the editor never produces. - normalized.visibility?.let { add(arrayOf(TAG_VISIBILITY, it.value)) } - - normalized.picture?.let { add(arrayOf(TAG_PICTURE, it)) } - normalized.domain?.let { add(arrayOf(TAG_DOMAIN, normalizeDomain(it))) } - - normalized.fields.forEach { add(it.toTagArray()) } - normalized.requireAny.forEach { group -> add(arrayOf(TAG_REQUIRE_ANY, *group.toTypedArray())) } - normalized.suggesters.forEach { add(arrayOf(TAG_SUGGESTER, it)) } - normalized.relays.forEach { add(arrayOf(TAG_RELAY, it)) } - } - - return EventTemplate( - createdAt = createdAt, - kind = KIND, - tags = tags.toTypedArray(), - content = normalized.description, - ) - } - - /** - * A hostname and nothing else: at least two labels, each of letters, digits - * and inner hyphens, no scheme, port or path, 253 characters at most. What - * [normalizeDomain] leaves of a pasted URL is checked against this, so - * `https://Bitcoin.MOV/` is accepted and stored as `bitcoin.mov`. - */ - private val DOMAIN = Regex( - "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$" - ) - - /** Trim the things people paste around a bare hostname. */ - fun normalizeDomain(value: String): String = value - .trim() - .lowercase() - .replace(Regex("^[a-z][a-z0-9+.-]*://"), "") - .removePrefix("@") - .replace(Regex("/.*$"), "") - .replace(Regex(":\\d+$"), "") - .removeSuffix(".") - - /** 64 lowercase hex characters: an x-only pubkey as nostr writes one. */ - fun isPubkey(value: String): Boolean = - value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' } - - fun isDomain(value: String): Boolean = - normalizeDomain(value).let { it.length <= MAX_DOMAIN && DOMAIN.matches(it) } - - /** - * An https URL with a host: the only kind of picture a schema may carry, - * because it gets rendered and browsers block the rest on a secure page. - */ - fun isHttpsUrl(value: String): Boolean = HTTPS_URL.matches(value.trim()) - - private val HTTPS_URL = Regex("^https://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE) - - /** - * A `ws://` or `wss://` URL with a host, which is what the NIP accepts in a - * `relay` tag. The editor is stricter about what it will let a group sign -- - * see `GroupRelaySet.relayUrlOrNull` -- but a schema read off a relay is held - * to the NIP's rule and not to this app's. - */ - fun isRelayUrl(value: String): Boolean = RELAY_URL.matches(value.trim()) - - private val RELAY_URL = Regex("^wss?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE) -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 4f4533d1..9ea861ce 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -9,11 +9,6 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -84,61 +79,6 @@ interface ChatRepository { */ suspend fun parentOf(chatRoomId: String): HexKey? - /** - * The kind:0 this group signed about itself, or null while it has signed - * none. - * - * The group's identity on nostr is the room's own id, so it has one to - * describe whether or not it ever has. Read off the group's signed events and - * verified there -- see `GroupNostrProfile` -- so a room shows the profile its - * own key signed or shows that it has none. - */ - suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? - - /** - * Where this group has said it lives on nostr: one entry per relay set, always - * all of them. - * - * A set the group has never agreed comes back unsigned and empty rather than - * missing, because "never said" is a state the screen has to show and a gap in - * a list is not one. See `GroupRelayList.isAgreed`. - */ - suspend fun groupRelayLists(chatRoomId: String): List - - /** - * The kind:1s this group signed, newest first. - * - * The group speaking as itself, which is a different thing from a member - * speaking in the room -- the transcript is the second. Unlike the profile and - * the relay lists these accumulate rather than replace, so this is a list and - * an empty one means the group has said nothing publicly. - */ - suspend fun groupPosts(chatRoomId: String): List - - /** - * The curated schemas this group signed -- the lists it curates -- one per - * identifier, most recently signed first. - * - * Kind 31889 is addressable, so an edit is a newer event under the same - * identifier and the newest wins per list, the way the profile does; two - * identifiers are two lists and both come back. Empty in a group that - * curates nothing, which is every group until somebody proposes one. - */ - suspend fun groupCuratedSchemas(chatRoomId: String): List - - /** - * The canonical entries this group signed -- the suggestions it accepted - * into the lists it curates -- one per coordinate, most recently signed - * first. - * - * Kind 31890 is addressable, so a re-curation is a newer event under the - * same identifier and the newest wins, the way a schema's edit does. Each - * is read against the schema it replies to, and an entry whose list this - * device holds no schema for is left out. Empty in a group that has - * accepted nothing, which is every group until a quorum signs an entry. - */ - suspend fun groupCuratedEntries(chatRoomId: String): List - /** * One event the group signed, as the group signed it, or null when this * device does not hold it or it was signed in some other room. @@ -331,21 +271,6 @@ interface ChatRepository { override suspend fun parentOf(chatRoomId: String): HexKey? = null - override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = null - - override suspend fun groupRelayLists(chatRoomId: String): List = - GroupRelayList.allAmong(emptyList(), chatRoomId) - - override suspend fun groupPosts(chatRoomId: String): List = emptyList() - - override suspend fun groupCuratedSchemas( - chatRoomId: String - ): List = emptyList() - - override suspend fun groupCuratedEntries( - chatRoomId: String - ): List = emptyList() - override suspend fun groupSignedEvent( chatRoomId: String, id: HexKey diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index cc6f7003..2ea083e5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -175,16 +175,6 @@ interface NostrRepository { suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> - /** - * Every stored event matching [synchronizationFilter], newest first, for as long as - * the caller collects. - * - * The filter is applied whole -- see `NostrEventFilterQuery` -- where [observeNostrFeed] - * recognises a handful of shapes and falls back to text notes for the rest. A reader - * that queued the same filter against a relay watches the answer arrive through this. - */ - suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> - suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean = true @@ -395,10 +385,6 @@ interface NostrRepository { TODO("Not yet implemented") } - override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { - TODO("Not yet implemented") - } - override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 926ab6c5..7b11f466 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -1,13 +1,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import press.mantra.compose.extensions.shortened import press.mantra.compose.managers.SharedKeyDerivation -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent @@ -161,70 +155,6 @@ object ProposedEvent { ).joinToString(" · ") ) - // The group's own profile. Named by what the group would be called and - // what it would say about itself, which is the whole of what is being - // decided; the JSON in the content is the one thing a signer should not - // have to read to find that out. - MetadataEvent.KIND -> Summary( - label = "Nostr profile", - detail = MetadataEvent( - event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig - ).contactMetaData()?.let { metadata -> - listOfNotNull( - metadata.anyName()?.takeIf { it.isNotBlank() }, - metadata.about?.takeIf { it.isNotBlank() } - ).joinToString(" · ").ifEmpty { "An empty profile" } - } ?: "Unreadable profile" - ) - - // A post in the group's name. The words are what is being decided, so - // they are the detail rather than a count of them -- the same call the - // translated-passage arm makes. - TextNoteEvent.KIND -> Summary(label = "Post", detail = event.content) - - // One of the group's relay lists, shown as hosts: every entry in one - // starts wss://, so the scheme is the part that never tells two apart. - // An empty one is a list the group is withdrawing, and says so. - in GroupRelaySet.KINDS -> { - val set = GroupRelaySet.entries.first { it.kind == event.kind } - - Summary( - label = "${set.name} relay list", - detail = set.parse(event.tags).joinToString { it.displayUrl() }.ifEmpty { "No relays" } - ) - } - - // A list the group would curate. A member signing this is agreeing to - // take suggestions from whoever the visibility admits and to be the one - // key that accepts them, so the summary is the list's name, who may - // suggest, and how much an entry asks for -- the content is only the - // description, which the detail screen shows whole. - CuratedSchemaEvent.KIND -> Summary( - label = "Curated schema", - detail = CuratedSchemaEvent.read(event.tags, event.content).let { schema -> - listOfNotNull( - schema.displayName().ifBlank { null }, - schema.visibility?.value, - schema.fields.size.let { "$it ${if (it == 1) "field" else "fields"}" } - ).joinToString(" · ") - } - ) - - // An entry the group would take into one of its lists. A member signing - // this is agreeing that the list stands behind this title, so the - // summary is the title and who suggested it -- the credit the entry - // carries -- and not its fields, which the form showed whole before the - // proposal was made and the queue shows again once it lands. - CuratedEntryEvent.CANONICAL_KIND -> Summary( - label = "Curated entry", - detail = CuratedEntryEvent.headline(event).let { entry -> - listOfNotNull( - entry.title.ifBlank { entry.identifier }.ifBlank { null }, - entry.source?.let { "suggested by ${it.pubKey.shortened()}" } - ).joinToString(" · ").ifEmpty { "An untitled entry" } - } - ) - else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt deleted file mode 100644 index dd0d5544..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt +++ /dev/null @@ -1,839 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.a_whole_number -import mantra.composeapp.generated.resources.add_another -import mantra.composeapp.generated.resources.at_least_n -import mantra.composeapp.generated.resources.at_least_one_of_these_is_needed -import mantra.composeapp.generated.resources.at_most_n -import mantra.composeapp.generated.resources.at_most_n_characters -import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_entry -import mantra.composeapp.generated.resources.field_and_problem -import mantra.composeapp.generated.resources.filled_in_by_the_suggester_s_app_and_kept -import mantra.composeapp.generated.resources.kept_from_the_suggestion -import mantra.composeapp.generated.resources.more_than_zero -import mantra.composeapp.generated.resources.must_match_the_pattern -import mantra.composeapp.generated.resources.one_word_without_spaces -import mantra.composeapp.generated.resources.only_one_value_for_this_field -import mantra.composeapp.generated.resources.pick_one_of_the_options -import mantra.composeapp.generated.resources.propose_entry -import mantra.composeapp.generated.resources.suggested_by_on -import mantra.composeapp.generated.resources.take_this_suggestion_into_the_list -import mantra.composeapp.generated.resources.that_is_not_a_web_address -import mantra.composeapp.generated.resources.that_must_be_an_https_address -import mantra.composeapp.generated.resources.the_entry_cannot_be_signed_as_it_is -import mantra.composeapp.generated.resources.the_group_already_has_an_entry_for_this -import mantra.composeapp.generated.resources.the_group_signs_an_entry_so_it_takes_a_quorum -import mantra.composeapp.generated.resources.this_entry_cannot_be_signed_as_it_is -import mantra.composeapp.generated.resources.this_field_is_required -import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_entry -import org.jetbrains.compose.resources.stringResource -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.toFormattedTimeAndDateString -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedEntryProblem -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.rememberNotifier -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.AcceptCuratedSuggestionViewModel -import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState -import kotlin.time.Instant - -/** - * The form for taking one suggestion into a list the group curates. - * - * The queue screen shows what was suggested; this is where the group decides - * what the list will say. Every field the schema's form asks for is an input - * seeded from the suggestion, because curation is editorial -- the NIP lets - * the curator fix a year or swap a poster on the way through, and a button - * that could only say yes would leave a typo in the list forever. What the - * form does not ask for it shows: the identifier, which the entry keeps so - * that accepting it twice revises one entry rather than making two, and the - * fields the suggester's app filled in, which this app has no way to fill in - * itself. - * - * **The button proposes rather than saves.** The entry goes out as one kind - * 31890 for the room's quorum to put its key to, pointing back at the - * suggestion it came from, and the screen hands over to the signing session it - * opened. Nothing about the list has changed by the time this screen closes. - * The form is driven by a schema this screen has never seen, so a value the - * schema refuses is named under its own input rather than found out after a - * quorum has signed. - */ -@OptIn( - ExperimentalMaterial3ExpressiveApi::class, - ExperimentalMaterial3Api::class, - ExperimentalLayoutApi::class -) -@Composable -fun AcceptCuratedSuggestionScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - identifier: String, - suggestionId: String, - initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState = - AcceptCuratedSuggestionUIState.Loading, - nostrRepository: NostrRepository, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository, - onNavigateBack: () -> Unit, - onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, -) { - val acceptCuratedSuggestionViewModel: AcceptCuratedSuggestionViewModel = viewModel( - factory = AcceptCuratedSuggestionViewModel.factory( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = identifier, - suggestionId = suggestionId, - initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - LaunchedEffect(true) { - if (initialAcceptCuratedSuggestionUIState == AcceptCuratedSuggestionUIState.Loading) { - acceptCuratedSuggestionViewModel.initiate() - } - } - - // Read out here rather than in a click handler: both are composable and an - // onClick lambda is not. The scope is the caller's so a message survives this - // screen being replaced by the signing session. - val notify = rememberNotifier(rememberCoroutineScope()) - val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_entry) - - ScreenStateTransition(acceptCuratedSuggestionViewModel.acceptCuratedSuggestionUIState) { uiState -> - when (val state = uiState) { - is AcceptCuratedSuggestionUIState.Error -> { - // Nothing to retry: the room, the schema and the suggestion came - // from navigation arguments, and reading them again with the - // same ones fails the same way. - ErrorState(message = state.message, onRetry = null) - } - - AcceptCuratedSuggestionUIState.Loading -> { - LoadingDataIndicator() - } - - is AcceptCuratedSuggestionUIState.Loaded -> { - val canSign = state.canSign - - // The inputs come from the state rather than from whatever loaded - // it, so a screen handed a loaded state -- a preview, a layout - // test -- fills in the same as the app does. Seeding runs once. - LaunchedEffect(state) { - acceptCuratedSuggestionViewModel.seedWorkingCopy(state) - } - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { state.localChatRoom.RenderChatRoomTitleText() }, - navigationIcon = { NavigateBackButton(onNavigateBack) } - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canSign) return@ExtendedFloatingActionButton - - acceptCuratedSuggestionViewModel.proposeEntry( - state = state, - onSuccess = { sessionId -> - // Onto the session rather than back - // to the queue. The entry is not in - // the list yet -- it goes in when a - // quorum signs -- so landing on a - // queue still showing the row as - // waiting would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - // The reasons are drawn on the form - // itself, under the inputs they are - // about; a snackbar would name a - // problem and scroll away from it. - onInvalid = {}, - // Stays on the form with the edits still - // in it: a failure to open a session is - // no reason to throw away the typing. - onFailure = { notify(couldNotPropose) } - ) - } - ) { - Icon(Icons.Default.Draw, contentDescription = "Propose entry") - Text(stringResource(Res.string.propose_entry)) - } - } - ) - } - ) { innerPadding -> - AcceptCuratedSuggestionForm( - state = state, - viewModel = acceptCuratedSuggestionViewModel, - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) - } - } - } - } -} - -/** - * The form under the bars: what is being taken up and into what, the reasons - * it cannot be as it stands, an input per field the schema asks for, and the - * fields it keeps. - * - * A lazy list rather than a scrolling column, the way the schema editor is, - * because the number of inputs is the schema's to decide and a long form is - * still one screen. Split out so a layout test can render it without the bars. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -internal fun AcceptCuratedSuggestionForm( - state: AcceptCuratedSuggestionUIState.Loaded, - viewModel: AcceptCuratedSuggestionViewModel, - modifier: Modifier = Modifier -) { - val schema = state.schema.schema - val suggestion = state.suggestion - val editable = AcceptCuratedSuggestionViewModel.editableFields(state) - val kept = AcceptCuratedSuggestionViewModel.keptFields(state) - val entryProblems = viewModel.problemsAboutTheEntry(state) - - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues( - horizontal = MaterialTheme.spacing.screenMargin, - vertical = MaterialTheme.spacing.itemGap - ), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Text( - text = suggestion.entry.title, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - item { - Text( - text = stringResource( - Res.string.suggested_by_on, - suggestion.suggesterName(), - suggestion.suggestedAt.toFormattedTimeAndDateString() - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - - item { - Text( - text = stringResource(Res.string.take_this_suggestion_into_the_list, schema.displayName()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - - item { - Text( - text = stringResource(Res.string.the_group_signs_an_entry_so_it_takes_a_quorum), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - - // Said up front rather than left to the queue's mark: a member who did - // not notice would propose what they took for a new entry and get a - // revision, which is the right outcome but a surprising one. - if (suggestion.isCurated) { - item { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.CheckCircle, - contentDescription = Decorative, - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) - - Text( - text = stringResource(Res.string.the_group_already_has_an_entry_for_this), - style = MaterialTheme.typography.bodySmall - ) - } - } - } - - if (!state.canSign) { - item { - Text( - text = stringResource(Res.string.this_group_has_no_shared_key_to_sign_an_entry), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - } - - // The problems no input can carry: about the entry as a whole, or about - // a field the form keeps rather than asks for. Named, and all at once, - // for the reason the schema editor gives -- a button that does nothing - // is indistinguishable from a missed tap. - if (entryProblems.isNotEmpty()) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - text = stringResource(Res.string.this_entry_cannot_be_signed_as_it_is), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - - entryProblems.forEach { problem -> - val field = schema.fields.firstOrNull { it.name == problem.field } - - Text( - text = stringResource( - Res.string.field_and_problem, - field?.labelOrName() ?: problem.field, - problem.message(field, schema) - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - } - - editable.forEach { field -> - item(key = field.name) { - val inputs = viewModel.inputs[field.name].orEmpty() - val problem = viewModel.problemsFor(field.name).firstOrNull() - - EntryFieldEditor( - field = field, - schema = schema, - inputs = inputs, - problem = problem, - onAddValue = { viewModel.addValue(field.name) }, - onRemoveValue = { index -> viewModel.removeValue(field.name, index) } - ) - } - } - - if (kept.isNotEmpty()) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) - - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(Res.string.kept_from_the_suggestion), - style = MaterialTheme.typography.titleSmall - ) - - Text( - text = stringResource(Res.string.filled_in_by_the_suggester_s_app_and_kept), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - kept.forEach { field -> - item(key = "kept:${field.name}") { - KeptField(field = field, values = suggestion.entry.values[field.name].orEmpty()) - } - } - } - } -} - -/** - * The inputs for one field, in the shape its type asks for. - * - * An enum is its options as chips, one selected, because typing a word that - * has to be one of six is a way to misspell it; everything else is a text - * field with the keyboard its type wants. A field that repeats is one input - * per value with a way to drop each and a way to add one, since the - * suggestion may have carried two watch links and the list may want a third. - * The schema's hint sits under the input until a problem takes its place. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun EntryFieldEditor( - field: CuratedField, - schema: CuratedSchema, - inputs: List, - problem: CuratedEntryProblem?, - onAddValue: () -> Unit, - onRemoveValue: (index: Int) -> Unit -) { - val message = problem?.message(field, schema) - val options = field.config.options - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - if (field.type == CuratedFieldType.Enum && !options.isNullOrEmpty()) { - EnumInput(field = field, options = options, input = inputs.firstOrNull(), message = message) - } else { - inputs.forEachIndexed { index, input -> - Row(verticalAlignment = Alignment.CenterVertically) { - EntryTextField( - modifier = Modifier.weight(1f), - state = input, - field = field, - // A problem is about the field, and the field is every - // input; the first is where a reader's eye lands. - message = message.takeIf { index == 0 }, - isError = problem != null - ) - - if (field.config.repeat == true) { - IconButton(onClick = { onRemoveValue(index) }) { - Icon( - Icons.Default.Delete, - contentDescription = "Remove this value", - tint = MaterialTheme.colorScheme.error - ) - } - } - } - } - - if (field.config.repeat == true) { - TextButton(onClick = onAddValue) { - Icon(Icons.Default.Add, contentDescription = Decorative) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) - Text(stringResource(Res.string.add_another)) - } - } - } - } -} - -/** - * An enum as chips. The selection is written into the field's one input, so - * the draft reads an enum the way it reads everything else; a value the - * suggestion carried that is not among the options is shown as an unselected - * set with the problem under it, rather than quietly dropped. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun EnumInput( - field: CuratedField, - options: List, - input: TextFieldState?, - message: String? -) { - val selected = input?.text?.toString() - - Text( - text = field.labelOrName(), - style = MaterialTheme.typography.labelMedium, - color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant - ) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - options.forEach { option -> - FilterChip( - selected = option == selected, - onClick = { input?.setTextAndPlaceCursorAtEnd(option) }, - label = { Text(option) } - ) - } - } - - (message ?: field.config.hint)?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -/** - * One text input of the form, in the shape the app's other forms use: the - * borderless outlined field over the bottom bar's tint, labelled as the - * schema labels it and placeholdered as the schema says. - * - * The keyboard follows the type -- digits for a year, a URL keyboard for a - * link -- and a long text gets room to be one. The supporting line is the - * problem when there is one and the schema's hint when there is not. - */ -@Composable -private fun EntryTextField( - state: TextFieldState, - field: CuratedField, - message: String?, - isError: Boolean, - modifier: Modifier = Modifier -) { - val keyboardType = when (field.type) { - CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> KeyboardType.Number - CuratedFieldType.Url, CuratedFieldType.Image -> KeyboardType.Uri - else -> KeyboardType.Text - } - - OutlinedTextField( - modifier = modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), - state = state, - lineLimits = if (field.type == CuratedFieldType.LongText) { - TextFieldLineLimits.MultiLine(minHeightInLines = 3) - } else { - TextFieldLineLimits.SingleLine - }, - isError = isError, - keyboardOptions = KeyboardOptions(keyboardType = keyboardType), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - label = { - Text(text = field.labelOrName()) - }, - placeholder = field.placeholder.takeIf { it.isNotBlank() }?.let { { Text(text = it) } }, - supportingText = (message ?: field.config.hint)?.let { { Text(text = it) } } - ) -} - -/** - * A field the form keeps rather than asks for: the schema's label over the - * values as the suggestion carried them, the way the queue's sheet shows an - * entry. Monospaced where the value is a thing rather than prose. - */ -@Composable -private fun KeptField(field: CuratedField, values: List) { - val isMonospaced = when (field.type) { - CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true - else -> false - } - - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = field.labelOrName(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = values.joinToString("\n"), - style = MaterialTheme.typography.bodyMedium, - fontFamily = if (isMonospaced) FontFamily.Monospace else null, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth() - ) - } -} - -/** - * One problem in words, for the input it is about. - * - * The bounds are read off the field's config, since "at most 500" says - * something and "too long" does not; a `require-any` rule names the other - * fields it could have been satisfied by, as the schema labels them. - */ -@Composable -internal fun CuratedEntryProblem.message(field: CuratedField?, schema: CuratedSchema): String = when (reason) { - CuratedEntryProblem.Reason.Missing, - CuratedEntryProblem.Reason.Empty -> stringResource(Res.string.this_field_is_required) - - CuratedEntryProblem.Reason.Repeated -> stringResource(Res.string.only_one_value_for_this_field) - CuratedEntryProblem.Reason.NotOneWord -> stringResource(Res.string.one_word_without_spaces) - CuratedEntryProblem.Reason.NotAUrl -> stringResource(Res.string.that_is_not_a_web_address) - CuratedEntryProblem.Reason.NotHttps -> stringResource(Res.string.that_must_be_an_https_address) - CuratedEntryProblem.Reason.NotAnOption -> stringResource(Res.string.pick_one_of_the_options) - CuratedEntryProblem.Reason.NotAWholeNumber -> stringResource(Res.string.a_whole_number) - CuratedEntryProblem.Reason.NotPositive -> stringResource(Res.string.more_than_zero) - - CuratedEntryProblem.Reason.BelowMinimum -> - stringResource(Res.string.at_least_n, field?.config?.min?.toString() ?: "") - - CuratedEntryProblem.Reason.AboveMaximum -> - stringResource(Res.string.at_most_n, field?.config?.max?.toString() ?: "") - - CuratedEntryProblem.Reason.TooLong -> - stringResource(Res.string.at_most_n_characters, field?.config?.max?.toString() ?: "") - - CuratedEntryProblem.Reason.PatternMismatch -> - stringResource(Res.string.must_match_the_pattern, field?.config?.pattern ?: "") - - CuratedEntryProblem.Reason.RequireAnyUnmet -> stringResource( - Res.string.at_least_one_of_these_is_needed, - schema.requireAny.firstOrNull { it.firstOrNull() == this.field } - .orEmpty() - .joinToString { name -> schema.fields.firstOrNull { it.name == name }?.labelOrName() ?: name } - ) - - // None of these can come out of the form: the builder writes the kind, the - // root and the source, and the group is the curator. Named all the same, - // since a check that cannot fail is the one that does. - CuratedEntryProblem.Reason.WrongKind, - CuratedEntryProblem.Reason.NotAReply, - CuratedEntryProblem.Reason.RepliesElsewhere, - CuratedEntryProblem.Reason.NotPermitted, - CuratedEntryProblem.Reason.NotTheCurator, - CuratedEntryProblem.Reason.SourceMalformed -> stringResource(Res.string.the_entry_cannot_be_signed_as_it_is) -} - -@ConformancePreviews -@Composable -private fun AcceptCuratedSuggestionScreenPreview() { - val chatRoomId = "d4c3b2a1".repeat(8) - val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - - val schema = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), - CuratedField( - "type", - CuratedFieldType.Enum, - isRequired = true, - label = "Type", - config = CuratedFieldConfig(options = listOf("movie", "documentary", "short")) - ), - CuratedField( - "watchUrl", - CuratedFieldType.Url, - isRequired = false, - label = "Watch URL", - config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true) - ), - CuratedField( - "description", - CuratedFieldType.LongText, - isRequired = false, - label = "Why", - config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) - ), - CuratedField( - "hashtags", - CuratedFieldType.Token, - isRequired = false, - label = "Hashtags", - config = CuratedFieldConfig(tag = "t", repeat = true, derived = true) - ), - ), - relays = listOf("wss://relay.one.example") - ) - ) - - val suggestion = CuratedSuggestion( - entry = CuratedEntry( - kind = CuratedSchemaEvent.SUGGESTION_KIND, - id = "a".repeat(64), - pubKey = suggester, - createdAt = 1_700_000_900, - identifier = "the-rise-and-rise-of-bitcoin-2014", - title = "The Rise and Rise of Bitcoin", - values = mapOf( - "identifier" to listOf("the-rise-and-rise-of-bitcoin-2014"), - "title" to listOf("The Rise and Rise of Bitcoin"), - "year" to listOf("2014"), - "type" to listOf("documentary"), - "watchUrl" to listOf("http://bitcoindoc.com/"), - "description" to listOf("Follows the early Bitcoin community from 2011 onward."), - "hashtags" to listOf("bitcoin", "documentary"), - ) - ), - event = NostrEvent( - id = "a".repeat(64), - pubKey = suggester, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = emptyArray(), - content = "", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_900) - ), - suggester = null, - isCurated = false - ) - - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - AcceptCuratedSuggestionScreen( - activeUserPublicKey = "u".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - identifier = "films", - suggestionId = suggestion.id, - initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "u".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - schema = schema, - suggestion = suggestion, - canSign = true - ), - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateBack = {}, - onNavigateToRouteAndPopUpInclusive = {} - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt deleted file mode 100644 index 208b9346..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt +++ /dev/null @@ -1,337 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.composable.widgets.rememberNotifier -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.AddGroupPostViewModel -import press.mantra.compose.ui.view.state.AddGroupPostUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.a_post_needs_something_to_say -import mantra.composeapp.generated.resources.add_post -import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_post -import mantra.composeapp.generated.resources.propose_post -import mantra.composeapp.generated.resources.the_group_signs_a_post_so_it_takes_a_quorum -import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_post -import mantra.composeapp.generated.resources.what_should_the_group_say -import org.jetbrains.compose.resources.stringResource - -/** - * The draft of something the group would say, in its own name. - * - * Not a message in the room: this is signed by the group's key and read by anybody, - * which is why the byline over the draft is the group's rather than the writer's. - * Whoever types it is proposing words for the group to put its signature to, and - * the words become a post -- attributed to the group and to nobody in particular -- - * only once a quorum has signed. - * - * The byline is the whole of the design here. Every other composer in this app puts - * the author's name over the draft because the author is the person typing; getting - * that wrong on this screen would have a member writing what looks like their own - * note and finding the group had said it. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) -@Composable -fun AddGroupPostScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository, - onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val addGroupPostViewModel: AddGroupPostViewModel = viewModel( - factory = AddGroupPostViewModel.factory( - chatRoomId = chatRoomId, - relayHint = relayHint, - initialAddGroupPostUIState = initialAddGroupPostUIState, - activeUserPublicKey = activeUserPublicKey, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - // Read out here rather than in the click handler: both are composable and an - // onClick lambda is not. The scope is the caller's so a message survives this - // screen being replaced by the signing session. - val notify = rememberNotifier(rememberCoroutineScope()) - val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_post) - val nothingToSay = stringResource(Res.string.a_post_needs_something_to_say) - - ScreenStateTransition(addGroupPostViewModel.addGroupPostUIState) { uiState -> - when (val addGroupPostUIState = uiState) { - is AddGroupPostUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, and - // reading it again with the same one fails the same way. - ErrorState(message = addGroupPostUIState.message, onRetry = null) - } - - is AddGroupPostUIState.Loaded -> { - val noteFieldState = rememberTextFieldState() - val canSign = addGroupPostUIState.canSign - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - // imePadding: this screen is a text field the size of the screen, - // and without it the software keyboard covers what is being typed. - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { Text(text = stringResource(Res.string.add_post)) }, - navigationIcon = { NavigateBackButton(onNavigateBack) }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canSign) return@ExtendedFloatingActionButton - - addGroupPostViewModel.proposePost( - localChatRoom = addGroupPostUIState.localChatRoom, - noteField = noteFieldState, - onSuccess = { sessionId -> - // Onto the session rather than back - // to the group. Nothing has been - // posted yet -- the post appears - // when a quorum signs -- so landing - // on the list it is not in would - // read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - // Both stay on the draft. One is an - // empty note and one is a failure to - // propose, and neither is a reason to - // throw away what was written. - onEmpty = { notify(nothingToSay) }, - onFailure = { notify(couldNotPropose) } - ) - } - ) { - Icon(Icons.Default.Draw, contentDescription = "Propose post") - Text(stringResource(Res.string.propose_post)) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Whose name this goes out in, said before a word is typed. - // It is the group's, and it is the one thing about this - // screen a member could reasonably get wrong. - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - ProfileAvatar( - size = MaterialTheme.spacing.space500, - publicKey = chatRoomId, - picture = addGroupPostUIState.groupNostrProfile?.picture(), - name = addGroupPostUIState.groupNostrProfile?.name() - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) - - Text( - text = addGroupPostUIState.groupNostrProfile?.name() - ?: addGroupPostUIState.localChatRoom.chatRoom.subject - ?: chatRoomId.take(16), - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = stringResource( - Res.string.the_group_signs_a_post_so_it_takes_a_quorum - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - if (!canSign) { - Text( - text = stringResource( - Res.string.this_group_has_no_shared_key_to_sign_a_post - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - - OutlinedTextField( - modifier = Modifier.fillMaxWidth().weight(1f) - .background(BottomAppBarDefaults.containerColor), - state = noteFieldState, - // The draft grows into the screen rather than scrolling - // inside three lines: a post is the one thing here - // somebody writes at length, and a composer that hides - // what was written two sentences ago is a composer - // nobody proofreads in. - lineLimits = TextFieldLineLimits.MultiLine(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - placeholder = { - Text(stringResource(Res.string.what_should_the_group_say)) - } - ) - } - } - } - - AddGroupPostUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - Text( - text = stringResource(Res.string.add_post), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator(fillScreen = false) - } - } - } - } - - LaunchedEffect(true) { - if (initialAddGroupPostUIState == AddGroupPostUIState.Loading) { - addGroupPostViewModel.initiateAddGroupPost() - } - } -} - -@ConformancePreviews -@Composable -private fun AddGroupPostScreenPreview() { - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - AddGroupPostScreen( - activeUserPublicKey = "", - chatRoomId = "publicKey", - relayHint = null, - initialAddGroupPostUIState = AddGroupPostUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = "publicKey", - userPublicKey = "", - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ), - ), - // A group that can sign, so the composer renders in the state a - // member actually meets it in rather than greyed out. - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index cfc9e191..411a5dcc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -81,13 +81,12 @@ import mantra.composeapp.generated.resources.signed_by_the_group_on import mantra.composeapp.generated.resources.that_is_not_a_relay_address import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list import mantra.composeapp.generated.resources.the_signed_event -import mantra.composeapp.generated.resources.where_the_group_has_said_it_lives_is_filled_in +import mantra.composeapp.generated.resources.the_relays_this_app_publishes_to_are_filled_in import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.network.relays.EventPublishTransport -import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.repository.ChatRepository import press.mantra.compose.text.ProposedEvent import press.mantra.compose.ui.composable.widgets.Decorative @@ -114,18 +113,19 @@ import kotlin.time.Instant * One event the group signed, and the relays to send it to. * * The group's screen shows what the group has signed; this is where one of those - * leaves the device. The list starts at where the group has said it lives and the - * member can change it, the button sends to every relay in it at once, and each - * row then says what its relay answered -- accepted, rejected in the relay's own + * leaves the device. The list starts at the app's own publish set and the member + * can change it, the button sends to every relay in it at once, and each row + * then says what its relay answered -- accepted, rejected in the relay's own * words, or nothing. The raw JSON is at the bottom for anyone who would rather * hand it to some other tool. * - * **The button sends rather than proposes.** Every other screen off the identity - * block opens a signing session, because what it makes is a statement by the - * group. This one moves a statement the group has already made, which any member - * holding it may do: the signature is the group's, and no share of the key is - * needed to repeat it. See `BroadcastGroupSignedEventViewModel` for why the send - * goes to the pool directly rather than through the app's broadcast queue. + * **The button sends rather than proposes.** Every screen that opens a signing + * session makes a statement by the group. This one moves a statement the group + * has already made, which any member holding it may do: the signature is the + * group's, and no share of the key is needed to repeat it. See + * `BroadcastGroupSignedEventViewModel` for why the send goes to the pool + * directly rather than through the app's broadcast queue -- and for the fact + * that, at present, nothing in the app opens this screen. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -180,13 +180,7 @@ fun BroadcastGroupSignedEventScreen( // test -- fills in the same as the app does. Once only, so a list // the member emptied stays empty. LaunchedEffect(broadcastGroupSignedEventUIState) { - broadcastGroupSignedEventViewModel.seedRelays( - relayLists = broadcastGroupSignedEventUIState.relayLists, - kind = signedEvent.kind, - tags = signedEvent.tags, - content = signedEvent.content, - schemas = broadcastGroupSignedEventUIState.schemas, - ) + broadcastGroupSignedEventViewModel.seedRelays(kind = signedEvent.kind) } // A refusal is about what is in the field, so it goes the moment @@ -317,7 +311,7 @@ fun BroadcastGroupSignedEventScreen( Text( text = stringResource( - Res.string.where_the_group_has_said_it_lives_is_filled_in + Res.string.the_relays_this_app_publishes_to_are_filled_in ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -605,10 +599,6 @@ private fun BroadcastGroupSignedEventScreenPreview() { signature = "f".repeat(128), createdAt = Instant.fromEpochSeconds(1_700_000_000) ), - // Nothing agreed, so the list fills in from the app's own - // publish set -- the state a group meets this screen in - // before it has edited its relays. - relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId), ), chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, publishTransport = object : EventPublishTransport { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index c6475bf2..37a3ae27 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -16,14 +16,8 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.Badge -import androidx.compose.material.icons.filled.Ballot -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.DataObject -import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.PersonAdd @@ -55,7 +49,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -76,15 +69,8 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute -import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute -import press.mantra.compose.extensions.inComparableGroups import press.mantra.compose.extensions.toFormattedTimeAndDateString import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -95,31 +81,11 @@ import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members -import mantra.composeapp.generated.resources.nostr_profile -import mantra.composeapp.generated.resources.relays -import mantra.composeapp.generated.resources.posts -import mantra.composeapp.generated.resources.curated_entries -import mantra.composeapp.generated.resources.curated_schemas -import mantra.composeapp.generated.resources.not_described_yet -import mantra.composeapp.generated.resources.n_of_m_relay_lists_agreed -import mantra.composeapp.generated.resources.no_posts_yet -import mantra.composeapp.generated.resources.one_post -import mantra.composeapp.generated.resources.n_posts -import mantra.composeapp.generated.resources.no_schemas_yet -import mantra.composeapp.generated.resources.one_schema -import mantra.composeapp.generated.resources.n_schemas -import mantra.composeapp.generated.resources.no_entries_yet -import mantra.composeapp.generated.resources.one_entry -import mantra.composeapp.generated.resources.n_entries -import mantra.composeapp.generated.resources.propose_event -import mantra.composeapp.generated.resources.no_name_on_this_profile import mantra.composeapp.generated.resources.parent_group import mantra.composeapp.generated.resources.a_subgroup import mantra.composeapp.generated.resources.created_you_are_not_a_member @@ -131,8 +97,6 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupRelaySet import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.add_artifact_to_library @@ -353,215 +317,6 @@ fun ChatRoomDetailScreen( HorizontalDivider() } - // Under the room's identity and above its work, because - // that is what it is: what the rest of nostr reads for - // the key the signing key row names. The room's subject - // and description above are this device's names for it; - // this is the group's own, signed by the group. - // - // One row per kind of statement, each opening the screen - // that shows it, rather than the statements themselves. - // Five sections of cards, every card with a button under - // it, made this screen as long as everything the group - // had ever said, with its members and its subgroups - // somewhere underneath. What a member opening it wants - // first is the shape of that -- a name and a few counts - // -- not every word of it. The row carries the shape; - // the screen behind it carries the rest, with the same - // broadcasts and the same gated ways in the sections had. - // - // Marmot rooms only, and absent rather than empty in a - // NIP-17 one. A NIP-17 room's id is not a key it can - // sign as, so it has no nostr identity at all -- and a - // row saying it has not described one yet would promise - // something that is never coming. - if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { - item { - SignedWorkRow( - icon = Icons.Default.Badge, - headline = stringResource(Res.string.nostr_profile), - // The name the group gave itself and its - // address where it claims one: the two - // things a reader checks a profile for. - // Named rather than falling back to the - // pubkey, for the reason the card gives. - supporting = chatRoomDetailUIState.groupNostrProfile - ?.let { profile -> - listOfNotNull( - profile.name() - ?: stringResource(Res.string.no_name_on_this_profile), - profile.nip05() - ).joinToString(" · ") - } - ?: stringResource(Res.string.not_described_yet), - onOpen = { - onNavigateToRoute.invoke( - GroupNostrProfileRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) - } - - // Directly under the profile, because between them - // they are the whole of the group's account of - // itself on nostr: who it says it is, and where it - // says it can be found. - item { - SignedWorkRow( - icon = Icons.Default.Dns, - headline = stringResource(Res.string.relays), - // How many of the four lists the group has - // agreed, counted against the sets rather - // than the entries: the entries come back - // empty in a room that has none of this to - // say, and "0 of 0" would say nothing. - supporting = stringResource( - Res.string.n_of_m_relay_lists_agreed, - chatRoomDetailUIState.groupRelayLists.count { it.isAgreed }, - GroupRelaySet.entries.size - ), - onOpen = { - onNavigateToRoute.invoke( - GroupRelaysRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) - } - - // Under the relay lists, because these are what the - // lists are *for*: the profile says who the group - // is, the relays say where to find it, and these are - // what a reader would find there. Still above the - // library, which is the group's work rather than its - // voice. - item { - SignedWorkRow( - icon = Icons.Default.Campaign, - headline = stringResource(Res.string.posts), - supporting = when (val count = chatRoomDetailUIState.groupPosts.size) { - 0 -> stringResource(Res.string.no_posts_yet) - 1 -> stringResource(Res.string.one_post) - else -> stringResource(Res.string.n_posts, count) - }, - onOpen = { - onNavigateToRoute.invoke( - GroupPostsRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) - } - - // Under the posts, because a schema is the other - // thing a group publishes for strangers to answer: - // a post is the group speaking, a schema is the - // group asking -- for entries to a list it will - // then curate, by quorum, under the same key. What - // it curated follows, in the row below. - item { - SignedWorkRow( - icon = Icons.Default.Ballot, - headline = stringResource(Res.string.curated_schemas), - supporting = when (val count = chatRoomDetailUIState.groupCuratedSchemas.size) { - 0 -> stringResource(Res.string.no_schemas_yet) - 1 -> stringResource(Res.string.one_schema) - else -> stringResource(Res.string.n_schemas, count) - }, - onOpen = { - onNavigateToRoute.invoke( - GroupCuratedSchemasRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) - } - - // Under the schemas, because these are what a - // schema is *for*: the schema asks, strangers - // answer on the queue, and these are the answers - // the group put its key to. They close the - // identity block because everything in it is - // signed by that key and read by people who were - // never in the room, and an accepted entry is the - // last thing in that chain. - item { - SignedWorkRow( - icon = Icons.Default.Checklist, - headline = stringResource(Res.string.curated_entries), - supporting = when (val count = chatRoomDetailUIState.groupCuratedEntries.size) { - 0 -> stringResource(Res.string.no_entries_yet) - 1 -> stringResource(Res.string.one_entry) - else -> stringResource(Res.string.n_entries, count) - }, - onOpen = { - onNavigateToRoute.invoke( - GroupCuratedEntriesRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) - } - - // The untyped way into everything above it. Each - // row opens a screen with a form that builds one - // kind of event; this takes any of those kinds as - // JSON, pasted from wherever it was made, and it - // lands on whichever screen is its kind's once the - // quorum signs -- the same reading, off the same - // signed events. Last in the block because it is - // about the block rather than about any one row of - // it, and behind the same gate for the same reason. - if (chatRoomDetailUIState.canEditNostrProfile) { - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ProposeGroupEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.DataObject, - contentDescription = Decorative - ) - - Spacer( - modifier = Modifier.width( - MaterialTheme.spacing.space125 - ) - ) - - Text(stringResource(Res.string.propose_event)) - } - } - } - - item { - HorizontalDivider() - } - } - item { // Artifacts Text( @@ -1290,44 +1045,20 @@ internal fun GroupKeyStateSheetContent( } /** - * One row of the identity block: a kind of statement the group has signed, a - * line summarising it, and the way to the screen where it is read in full. + * A long hex value in groups of eight, which is what makes it readable and what + * lets it wrap at all. * - * The same shape as the signing key and parent group rows above it -- a card - * with a leading icon and a trailing chevron -- because it is the same kind of - * thing: a fact about the group, opened by tapping. The icons are decorative - * because the headline beside each says what the row is, and the chevron says - * only what every card on this screen already says by being one. + * Both halves matter. A 64-character key is compared against another member's + * screen a character at a time, and the eye loses its place in an undivided run + * of hex -- the same reason a fingerprint or an account number is grouped. + * + * And a run of hex has no space in it, so Compose has nowhere to break the line: + * it lays the whole thing out on one and lets it run off the side of the sheet, + * where the end of the key cannot be read at all. The spaces are the break + * opportunities. Nothing is copied from these -- the copy button takes the + * signed event, which is not grouped -- so the display can be shaped for reading + * without a paste losing the value. */ -@Composable -private fun SignedWorkRow( - icon: ImageVector, - headline: String, - supporting: String, - onOpen: () -> Unit -) { - Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) { - ListItem( - leadingContent = { - Icon(icon, contentDescription = Decorative) - }, - trailingContent = { - Icon(Icons.Default.ChevronRight, contentDescription = Decorative) - }, - headlineContent = { - Text(text = headline) - }, - supportingContent = { - Text( - text = supporting, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - } -} - /** * One subgroup, as the parent's record and this device's rooms together have it. * @@ -1378,6 +1109,8 @@ private fun SubgroupCard( } } +private fun String.inComparableGroups(): String = chunked(8).joinToString(" ") + /** One labelled value of a key state, whole and comparable. */ @Composable private fun GroupKeyStateField( @@ -1508,10 +1241,7 @@ private fun ChatRoomMessagingScreenPreview() { subject = "Message title", description = "Description of a chat room so that members now why they are here.", initialGiftWrapPayloadId = "sdfaer", - // A Marmot room, so the nostr profile section renders - // at all -- it is hidden in a NIP-17 one, which has no - // key to have an identity on nostr. - mlsGroupState = "state" + mlsGroupState = null ), localParticipants = listOf( LocalParticipant( @@ -1539,29 +1269,7 @@ private fun ChatRoomMessagingScreenPreview() { derivationPath = "m/9420/0/0", announcedBy = "d".repeat(64), announcedAt = Instant.fromEpochSeconds(1_700_000_000) - ), - // A group that has described itself, so the section renders - // with something in it rather than only in its empty state. - // Built directly rather than read out of a signed event: the - // reading checks a signature, and there is no real one here. - groupNostrProfile = GroupNostrProfile( - signedEvent = GroupSignedEvent( - id = "e".repeat(64), - chatRoomId = "publicKey", - publicKey = "publicKey", - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - metadata = UserMetadata().apply { - displayName = "Translation collective" - about = "A group translating hard books into Sesotho." - nip05 = "group@example.com" - } - ), - canEditNostrProfile = true + ) ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt deleted file mode 100644 index 8f7b69c6..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt +++ /dev/null @@ -1,675 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.accept_this_suggestion -import mantra.composeapp.generated.resources.ask_again -import mantra.composeapp.generated.resources.asking_the_list_s_relays -import mantra.composeapp.generated.resources.curated -import mantra.composeapp.generated.resources.everything_anyone_has_suggested_to_this_list -import mantra.composeapp.generated.resources.n_curated -import mantra.composeapp.generated.resources.n_suggestions -import mantra.composeapp.generated.resources.nobody_has_suggested_anything_to_this_list_yet -import mantra.composeapp.generated.resources.one_suggestion -import mantra.composeapp.generated.resources.read_from_relays -import mantra.composeapp.generated.resources.revise_the_list_s_entry -import mantra.composeapp.generated.resources.suggested_by_on -import mantra.composeapp.generated.resources.suggestions -import mantra.composeapp.generated.resources.taken_up_by_the_group_into_the_list -import org.jetbrains.compose.resources.stringResource -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.toFormattedTimeAndDateString -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.navigation.routes.AcceptCuratedSuggestionRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.EmptyState -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet -import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.CuratedSuggestionListViewModel -import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState -import kotlin.time.Instant - -/** - * Everything anyone has suggested to one list the group curates. - * - * The group's screen shows the schema: what the list asks for. This is what it - * has been answered with -- one row per suggestion, newest first, whoever - * signed it -- which is the view a member wants before the group takes - * anything up: to see what is waiting, and what has already been taken. - * - * Read from the relays the schema names, and the header says which, because a - * queue read from the wrong relays and a list nobody has written to look the - * same from here. A suggestion that does not satisfy the schema is not on this - * screen at all; see `CuratedSuggestion`. - * - * A row opens a sheet with the whole entry, field by field, and the one thing - * a member can do about it from here: take it into the list, which opens the - * form that proposes the canonical entry for the group to sign. - */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun CuratedSuggestionListScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - identifier: String, - initialCuratedSuggestionListUIState: CuratedSuggestionListUIState = - CuratedSuggestionListUIState.Loading, - nostrRepository: NostrRepository, - chatRepository: ChatRepository, - onNavigateBack: () -> Unit, - onNavigateToRoute: (Route) -> Unit, -) { - val curatedSuggestionListViewModel: CuratedSuggestionListViewModel = viewModel( - factory = CuratedSuggestionListViewModel.factory( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint, - identifier = identifier, - initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - ) - ) - - LaunchedEffect(true) { - if (initialCuratedSuggestionListUIState == CuratedSuggestionListUIState.Loading) { - curatedSuggestionListViewModel.initiate() - } - } - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.suggestions), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { NavigateBackButton(onNavigateBack) } - ) - } - ) { padding -> - ScreenStateTransition(curatedSuggestionListViewModel.curatedSuggestionListUIState) { uiState -> - when (val state = uiState) { - is CuratedSuggestionListUIState.Error -> { - // Nothing to retry: the room and the identifier came from a - // navigation argument, and reading them again with the same - // ones fails the same way. - ErrorState( - modifier = Modifier.padding(padding), - message = state.message, - onRetry = null - ) - } - - CuratedSuggestionListUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - LoadingDataIndicator(fillScreen = false) - } - } - - is CuratedSuggestionListUIState.Loaded -> { - // The id rather than the row, so the sheet follows an edit - // that replaces the row under it, and survives a rotation. - var openSuggestionId by rememberSaveable { mutableStateOf(null) } - - LazyColumn( - modifier = Modifier.padding(padding).readableContent().fillMaxSize() - .padding(MaterialTheme.spacing.space250), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - item { - CuratedQueueHeader(state = state) - } - - if (state.suggestions.isEmpty()) { - item { - if (state.isAsking) { - LoadingDataIndicator( - fillScreen = false, - text = stringResource(Res.string.asking_the_list_s_relays) - ) - } else { - // Empty is a fact about the relays that were - // asked, and asking again is the one thing a - // member can do about it from here. - EmptyState( - message = stringResource( - Res.string.nobody_has_suggested_anything_to_this_list_yet - ), - action = { - TextButton( - onClick = { curatedSuggestionListViewModel.askAgain() } - ) { - Text(stringResource(Res.string.ask_again)) - } - } - ) - } - } - } else { - items( - items = state.suggestions, - key = { suggestion -> suggestion.id } - ) { suggestion -> - CuratedSuggestionCard( - suggestion = suggestion, - schema = state.schema.schema, - onOpen = { openSuggestionId = suggestion.id } - ) - } - } - } - - openSuggestionId - ?.let { id -> state.suggestions.firstOrNull { it.id == id } } - ?.let { suggestion -> - CuratedSuggestionSheet( - suggestion = suggestion, - schema = state.schema.schema, - onAccept = { - openSuggestionId = null - onNavigateToRoute( - AcceptCuratedSuggestionRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = identifier, - suggestionId = suggestion.id - ) - ) - }, - onDismiss = { openSuggestionId = null } - ) - } - } - } - } - } -} - -/** - * What the queue is, in numbers, and where it was read from. - * - * The list is named the way its card on the group's screen names it -- the - * domain where it claims one, else the name -- so a member arriving from that - * card finds the same word. The counts are the reference client's line: how - * many are waiting, and how many of those the group has already taken up. - */ -@Composable -private fun CuratedQueueHeader(state: CuratedSuggestionListUIState.Loaded) { - Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { - Text( - text = state.schema.schema.displayName(), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = stringResource(Res.string.everything_anyone_has_suggested_to_this_list), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = listOfNotNull( - when (state.suggestions.size) { - 1 -> stringResource(Res.string.one_suggestion) - else -> stringResource(Res.string.n_suggestions, state.suggestions.size) - }, - state.curatedCount.takeIf { it > 0 }?.let { stringResource(Res.string.n_curated, it) } - ).joinToString(" · "), - style = MaterialTheme.typography.labelMedium - ) - - if (state.relays.isNotEmpty()) { - Text( - text = stringResource( - Res.string.read_from_relays, - state.relays.joinToString { relay -> - RelayUrlNormalizer.normalizeOrNull(relay)?.displayUrl() ?: relay - } - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -/** - * One suggestion, as a row somebody is scanning. - * - * The title, because every list has one; a glance at the fields that say what - * the entry *is* -- the type, the year, the director -- and not the ones that - * say where to find it, since a URL on a row is a line of noise and the sheet - * has them; the body where the list has one; and who suggested it, when. The - * avatar is the suggester's, tinted from their key like every avatar here, so - * two suggestions by one stranger read as one stranger. - * - * "Curated" on the row is the one thing on it a member might act on: it says - * the group already took this up, and re-curating it would revise the entry - * rather than add one. - */ -@Composable -private fun CuratedSuggestionCard( - suggestion: CuratedSuggestion, - schema: CuratedSchema, - onOpen: () -> Unit -) { - val entry = suggestion.entry - - Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) { - ListItem( - leadingContent = { - ProfileAvatar(publicKey = entry.pubKey, profile = suggestion.suggester) - }, - trailingContent = if (suggestion.isCurated) { - { CuratedMark() } - } else { - null - }, - headlineContent = { - Text( - text = entry.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - glanceOf(entry, schema)?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - bodyOf(entry, schema)?.let { - Text( - text = it, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = stringResource( - Res.string.suggested_by_on, - suggestion.suggesterName(), - suggestion.suggestedAt.toFormattedTimeAndDateString() - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - } -} - -@Composable -private fun CuratedMark() { - Row(verticalAlignment = Alignment.CenterVertically) { - // The word beside it says it, so the icon says nothing twice. - Icon( - Icons.Default.CheckCircle, - contentDescription = Decorative, - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) - - Text( - text = stringResource(Res.string.curated), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - } -} - -/** - * The fields worth a glance on a row, as `Label: value`, or null when the entry - * has none of them. - * - * The fields a form prompts for, less the title (the headline), the body (its - * own line) and anything that is a link (the sheet). Labelled because a bare - * "2014 · en" says nothing to somebody who does not know the list's fields, - * and this screen is driven by a schema it has never seen before. Shared with - * the group's screen, so an entry is described the same way on both. - */ -internal fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = - schema.formFields() - .filterNot { it.tag() == TITLE_TAG || it.tag() == CuratedFieldConfig.CONTENT_TAG } - .filterNot { it.type == CuratedFieldType.Url || it.type == CuratedFieldType.Image } - .mapNotNull { field -> - entry.values[field.name]?.let { values -> "${field.labelOrName()}: ${values.joinToString()}" } - } - .takeIf { it.isNotEmpty() } - ?.joinToString(" · ") - -/** The value of the field that writes to the body, or null. */ -internal fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = - schema.fields.firstOrNull { it.tag() == CuratedFieldConfig.CONTENT_TAG }?.let { entry.value(it.name) } - -private const val TITLE_TAG = "title" - -/** - * The whole of one suggestion, and the way to take it into the list. - * - * Every field the entry has a value for, labelled as the schema labels it, so - * the sheet reads as the form would have -- and then the button, which is the - * one thing a member can do about a suggestion: open the form that proposes it - * as the list's own, for the group to sign. The event itself is not shown; it - * is what the form is built from, and the form shows it as fields rather than - * as JSON, which is how a member reads it and how the group will sign it. - */ -@Composable -private fun CuratedSuggestionSheet( - suggestion: CuratedSuggestion, - schema: CuratedSchema, - onAccept: () -> Unit, - onDismiss: () -> Unit -) { - ModalBottomSheet(onDismiss = onDismiss) { - CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema, onAccept = onAccept) - } -} - -/** - * The sheet's body, apart from the sheet. Split out so it can be rendered and - * measured on its own, since a bottom sheet is a popup a layout test cannot - * reach into. - */ -@Composable -internal fun CuratedSuggestionSheetContent( - suggestion: CuratedSuggestion, - schema: CuratedSchema, - onAccept: () -> Unit -) { - val entry = suggestion.entry - - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = entry.title, - style = MaterialTheme.typography.titleMedium - ) - - Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) - - Text( - text = stringResource( - Res.string.suggested_by_on, - suggestion.suggesterName(), - suggestion.suggestedAt.toFormattedTimeAndDateString() - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - if (suggestion.isCurated) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) - - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.CheckCircle, - contentDescription = Decorative, - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) - - Text( - text = stringResource(Res.string.taken_up_by_the_group_into_the_list), - style = MaterialTheme.typography.bodyMedium - ) - } - } - - Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) - - // In the schema's order, which is the order the form asked in, and - // only the fields the entry answered: a form's empty inputs are not - // worth a line each here. The title is the heading and is not said twice. - schema.fields.filterNot { it.tag() == TITLE_TAG }.forEach { field -> - entry.values[field.name]?.let { values -> - CuratedSuggestionField(field = field, values = values) - } - } - - Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) - - // The same button either way, worded for what it will do: a suggestion - // the group already took up is revised in place, since the canonical - // entry keeps its identifier, and a member should not have to find that - // out from the queue afterwards. - Button( - onClick = onAccept, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Draw, contentDescription = Decorative) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) - Text( - text = stringResource( - if (suggestion.isCurated) { - Res.string.revise_the_list_s_entry - } else { - Res.string.accept_this_suggestion - } - ) - ) - } - } -} - -/** - * One field of an entry: the schema's label over the value, or the values one - * under another for a field that repeats. Monospaced where the value is a - * thing rather than prose -- a token, a link -- because those are compared and - * copied rather than read. - */ -@Composable -private fun CuratedSuggestionField(field: CuratedField, values: List) { - val isMonospaced = when (field.type) { - CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true - else -> false - } - - Text( - text = field.labelOrName(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = values.joinToString("\n"), - style = MaterialTheme.typography.bodyMedium, - fontFamily = if (isMonospaced) FontFamily.Monospace else null, - // Stated rather than inherited: the label above is deliberately quieter, - // and the value is the thing that was come for. - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) -} - -@ConformancePreviews -@Composable -private fun CuratedSuggestionListScreenPreview() { - val chatRoomId = "d4c3b2a1".repeat(8) - val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - - val schema = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), - CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"), - CuratedField( - "description", - CuratedFieldType.LongText, - isRequired = false, - label = "Why", - config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) - ), - ), - relays = listOf("wss://relay.one.example") - ) - ) - - fun suggestion(title: String, createdAt: Long, isCurated: Boolean) = CuratedSuggestion( - entry = CuratedEntry( - kind = CuratedSchemaEvent.SUGGESTION_KIND, - id = createdAt.toString().padStart(64, 'a'), - pubKey = suggester, - createdAt = createdAt, - identifier = title.lowercase().replace(' ', '-'), - title = title, - values = mapOf( - "title" to listOf(title), - "year" to listOf("2014"), - "type" to listOf("documentary"), - "description" to listOf("Follows the early Bitcoin community from 2011 onward."), - ) - ), - event = NostrEvent( - id = createdAt.toString().padStart(64, 'a'), - pubKey = suggester, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = emptyArray(), - content = "", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ), - suggester = null, - isCurated = isCurated - ) - - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - CuratedSuggestionListScreen( - activeUserPublicKey = "u".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - identifier = "films", - initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "u".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - schema = schema, - relays = listOf("wss://relay.one.example"), - suggestions = listOf( - suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), - suggestion("Magic Money", 1_700_000_000, isCurated = false), - ) - ), - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateBack = {}, - onNavigateToRoute = {} - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt deleted file mode 100644 index 6a113f52..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt +++ /dev/null @@ -1,1525 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Label -import androidx.compose.material.icons.automirrored.filled.Notes -import androidx.compose.material.icons.automirrored.filled.Rule -import androidx.compose.material.icons.automirrored.filled.ShortText -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Ballot -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Checklist -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Lightbulb -import androidx.compose.material.icons.filled.Numbers -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.Sell -import androidx.compose.material.icons.filled.Tag -import androidx.compose.material.icons.filled.Title -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.hexToNpubHrp -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSchemaProblem -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet -import press.mantra.compose.ui.composable.widgets.rememberNotifier -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel -import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddRelayFailure -import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddSuggesterFailure -import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldEdit -import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldProblem -import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.a_description_is_required -import mantra.composeapp.generated.resources.a_domain_replaces_the_name_wherever_the_list_is_shown -import mantra.composeapp.generated.resources.a_field_name_is_one_word -import mantra.composeapp.generated.resources.a_field_named_that_already_exists -import mantra.composeapp.generated.resources.a_field_needs_a_name -import mantra.composeapp.generated.resources.a_name_is_required -import mantra.composeapp.generated.resources.a_regular_expression_the_whole_value_must_match -import mantra.composeapp.generated.resources.a_schema_needs_at_least_one_field -import mantra.composeapp.generated.resources.a_title_is_required -import mantra.composeapp.generated.resources.add_field -import mantra.composeapp.generated.resources.add_rule -import mantra.composeapp.generated.resources.add_schema -import mantra.composeapp.generated.resources.an_entry_must_have_this_field -import mantra.composeapp.generated.resources.an_identifier_is_required -import mantra.composeapp.generated.resources.at_least_one_of -import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_schema -import mantra.composeapp.generated.resources.defaults_to_the_field_name_content_means_the_body -import mantra.composeapp.generated.resources.derived -import mantra.composeapp.generated.resources.description -import mantra.composeapp.generated.resources.domain -import mantra.composeapp.generated.resources.done -import mantra.composeapp.generated.resources.edit_field -import mantra.composeapp.generated.resources.edit_schema -import mantra.composeapp.generated.resources.eg_1900 -import mantra.composeapp.generated.resources.eg_200 -import mantra.composeapp.generated.resources.eg_2100 -import mantra.composeapp.generated.resources.eg_a_za_z_2_8 -import mantra.composeapp.generated.resources.eg_bitcoin_mov -import mantra.composeapp.generated.resources.eg_movie_documentary_short -import mantra.composeapp.generated.resources.eg_bitcoin_mov_suggestion -import mantra.composeapp.generated.resources.eg_example_com -import mantra.composeapp.generated.resources.eg_fields_for_a_bitcoin_mov_entry -import mantra.composeapp.generated.resources.eg_https_example_com_group_png -import mantra.composeapp.generated.resources.eg_https_youtube_com_watch -import mantra.composeapp.generated.resources.eg_watch -import mantra.composeapp.generated.resources.eg_watch_reference_url -import mantra.composeapp.generated.resources.eg_watchurl -import mantra.composeapp.generated.resources.eg_wss_relay_example_com -import mantra.composeapp.generated.resources.field_name -import mantra.composeapp.generated.resources.field_type_duration -import mantra.composeapp.generated.resources.field_type_enum -import mantra.composeapp.generated.resources.field_type_image -import mantra.composeapp.generated.resources.field_type_longtext -import mantra.composeapp.generated.resources.field_type_number -import mantra.composeapp.generated.resources.field_type_text -import mantra.composeapp.generated.resources.field_type_token -import mantra.composeapp.generated.resources.field_type_url -import mantra.composeapp.generated.resources.field_type_year -import mantra.composeapp.generated.resources.fields -import mantra.composeapp.generated.resources.filled_in_by_the_app_never_asked_for -import mantra.composeapp.generated.resources.helper_text_under_the_input -import mantra.composeapp.generated.resources.hint -import mantra.composeapp.generated.resources.https_only -import mantra.composeapp.generated.resources.identifier -import mantra.composeapp.generated.resources.label -import mantra.composeapp.generated.resources.marker -import mantra.composeapp.generated.resources.maximum_characters -import mantra.composeapp.generated.resources.maximum_value -import mantra.composeapp.generated.resources.may_appear_more_than_once -import mantra.composeapp.generated.resources.minimum_value -import mantra.composeapp.generated.resources.name -import mantra.composeapp.generated.resources.new_field -import mantra.composeapp.generated.resources.no_relays_named_so_a_client_will_pick_its_own -import mantra.composeapp.generated.resources.nothing_has_changed_to_propose -import mantra.composeapp.generated.resources.npub_or_hex_pubkey -import mantra.composeapp.generated.resources.one_of_the_relays_is_not_a_relay_address -import mantra.composeapp.generated.resources.one_per_line_or_separated_by_commas -import mantra.composeapp.generated.resources.optional -import mantra.composeapp.generated.resources.options -import mantra.composeapp.generated.resources.pattern -import mantra.composeapp.generated.resources.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have -import mantra.composeapp.generated.resources.picture_url -import mantra.composeapp.generated.resources.placeholder -import mantra.composeapp.generated.resources.propose_schema -import mantra.composeapp.generated.resources.pubkeys_besides_the_group_that_may_suggest_to_this_list -import mantra.composeapp.generated.resources.relay_url -import mantra.composeapp.generated.resources.relays -import mantra.composeapp.generated.resources.remove_field -import mantra.composeapp.generated.resources.required -import mantra.composeapp.generated.resources.say_who_may_suggest_to_this_list -import mantra.composeapp.generated.resources.suggesters -import mantra.composeapp.generated.resources.that_is_not_a_domain_name -import mantra.composeapp.generated.resources.that_is_not_a_pubkey -import mantra.composeapp.generated.resources.that_is_not_a_relay_address -import mantra.composeapp.generated.resources.that_pubkey_is_already_a_suggester -import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list -import mantra.composeapp.generated.resources.the_description_must_be_at_most_n_characters -import mantra.composeapp.generated.resources.the_group_signs_a_schema_so_it_takes_a_quorum -import mantra.composeapp.generated.resources.the_identifier_must_be_at_most_n_characters -import mantra.composeapp.generated.resources.the_identifier_names_this_list_in_every_reply -import mantra.composeapp.generated.resources.the_name_must_be_at_most_n_characters -import mantra.composeapp.generated.resources.the_picture_must_be_an_https_url -import mantra.composeapp.generated.resources.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart -import mantra.composeapp.generated.resources.the_title_must_be_at_most_n_characters -import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_schema -import mantra.composeapp.generated.resources.title -import mantra.composeapp.generated.resources.type -import mantra.composeapp.generated.resources.visibility -import mantra.composeapp.generated.resources.visibility_closed -import mantra.composeapp.generated.resources.visibility_closed_purpose -import mantra.composeapp.generated.resources.visibility_private -import mantra.composeapp.generated.resources.visibility_private_purpose -import mantra.composeapp.generated.resources.visibility_public -import mantra.composeapp.generated.resources.visibility_public_purpose -import mantra.composeapp.generated.resources.what_an_entry_in_this_list_may_contain -import mantra.composeapp.generated.resources.where_suggestions_to_this_list_are_published -import mantra.composeapp.generated.resources.writes_to_tag -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource - -/** - * The form for a list the group curates: what an entry may contain, who may - * suggest one, and where suggestions go. - * - * One screen for the whole schema, because the schema is one event and one - * signature. The identity -- identifier, title, name, description, visibility, - * picture, domain -- is text fields like every other editor's. Under it are the - * four things a schema has several of: fields, rules saying at least one of some - * fields must be present, extra suggesters on a closed list, and relays. Each - * is a list with a row per entry and a way to add one, and a field is edited on - * a sheet of its own, since a field has a dozen settings and thirteen of them - * inline would be a screen nobody could find anything on. - * - * **The button proposes rather than saves.** The schema goes out as one event - * for the room's quorum to put its key to, and the screen hands over to the - * signing session it opened. Nothing about the list has changed by the time this - * screen closes -- and until a quorum signs, suggesters have nothing to reply to. - * - * **An edit keeps the identifier.** The identifier is the coordinate every - * suggestion to the list replies to, so changing it would not edit the list but - * start a second one and orphan the first's queue. The field is read-only on an - * edit and says why; a new list is "Add schema" on the group's screen. - */ -@OptIn( - ExperimentalMaterial3ExpressiveApi::class, - ExperimentalMaterial3Api::class, - ExperimentalLayoutApi::class -) -@Composable -fun EditGroupCuratedSchemaScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - identifier: String?, - initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState = - EditGroupCuratedSchemaUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository, - onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val editGroupCuratedSchemaViewModel: EditGroupCuratedSchemaViewModel = viewModel( - factory = EditGroupCuratedSchemaViewModel.factory( - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = identifier, - initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState, - activeUserPublicKey = activeUserPublicKey, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - // Read out here rather than in a click handler: both are composable and an - // onClick lambda is not. The scope is the caller's so a message survives this - // screen being replaced by the signing session. - val notify = rememberNotifier(rememberCoroutineScope()) - val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_schema) - val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose) - - ScreenStateTransition(editGroupCuratedSchemaViewModel.editGroupCuratedSchemaUIState) { uiState -> - when (val editGroupCuratedSchemaUIState = uiState) { - is EditGroupCuratedSchemaUIState.Error -> { - // Nothing to retry: the room and the identifier came from a - // navigation argument, and reading them again with the same ones - // fails the same way. - ErrorState(message = editGroupCuratedSchemaUIState.message, onRetry = null) - } - - is EditGroupCuratedSchemaUIState.Loaded -> { - val existing = editGroupCuratedSchemaUIState.existing - val schema = existing?.schema - val canSign = editGroupCuratedSchemaUIState.canSign - - // Seeded from the schema the group already signed, so the form is - // an edit of it. `rememberTextFieldState` saves what is typed, which - // is what survives a rotation with the edits intact. - val identifierFieldState = rememberTextFieldState(schema?.identifier ?: "") - val titleFieldState = rememberTextFieldState(schema?.title ?: "") - val nameFieldState = rememberTextFieldState(schema?.name ?: "") - val descriptionFieldState = rememberTextFieldState(schema?.description ?: "") - val pictureFieldState = rememberTextFieldState(schema?.picture ?: "") - val domainFieldState = rememberTextFieldState(schema?.domain ?: "") - val relayFieldState = rememberTextFieldState() - val suggesterFieldState = rememberTextFieldState() - - // The lists come from the state rather than from whatever loaded - // it, so a screen handed a loaded state -- a preview, a layout - // test -- fills in the same as the app does. Seeding runs once. - LaunchedEffect(editGroupCuratedSchemaUIState) { - editGroupCuratedSchemaViewModel.seedWorkingCopy(editGroupCuratedSchemaUIState) - } - - // A refusal is about what is in the field, so it goes the moment - // the field changes rather than sitting under a value that has - // since been corrected. - LaunchedEffect(relayFieldState) { - snapshotFlow { relayFieldState.text.toString() } - .collect { editGroupCuratedSchemaViewModel.clearAddRelayFailure() } - } - LaunchedEffect(suggesterFieldState) { - snapshotFlow { suggesterFieldState.text.toString() } - .collect { editGroupCuratedSchemaViewModel.clearAddSuggesterFailure() } - } - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { - editGroupCuratedSchemaUIState.localChatRoom.RenderChatRoomTitleText() - }, - navigationIcon = { NavigateBackButton(onNavigateBack) }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canSign) return@ExtendedFloatingActionButton - - editGroupCuratedSchemaViewModel.proposeSchema( - localChatRoom = - editGroupCuratedSchemaUIState.localChatRoom, - existing = existing, - identifier = identifierFieldState.text.toString(), - title = titleFieldState.text.toString(), - name = nameFieldState.text.toString(), - description = descriptionFieldState.text.toString(), - picture = pictureFieldState.text.toString(), - domain = domainFieldState.text.toString(), - onSuccess = { sessionId -> - // Onto the session rather than back - // to the group. The schema has not - // changed yet -- it changes when a - // quorum signs -- so landing on a - // group still showing the old one - // would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - // The reasons are drawn on the form - // itself, where the fields they are - // about are; a snackbar would name a - // problem and scroll away from it. - onInvalid = {}, - // Both stay on the form with the edits - // still in it. One is a question and - // one is a failure, and neither is a - // reason to throw away the typing. - onNothingToPropose = { notify(nothingChanged) }, - onFailure = { notify(couldNotPropose) } - ) - } - ) { - Icon(Icons.Default.Draw, contentDescription = "Propose schema") - Text(stringResource(Res.string.propose_schema)) - } - } - ) - } - ) { innerPadding -> - // One lazy list for the whole form rather than a scrolling - // column, because the form has four lists in it and a lazy - // list cannot sit inside a scrolling column. The text fields - // are declared above so that scrolling one off the screen does - // not lose what was typed into it. - LazyColumn( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), - contentPadding = PaddingValues( - horizontal = MaterialTheme.spacing.screenMargin, - vertical = MaterialTheme.spacing.itemGap - ), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Text( - text = stringResource(Res.string.what_an_entry_in_this_list_may_contain), - style = MaterialTheme.typography.titleSmall, - textAlign = TextAlign.Center - ) - } - - item { - Text( - text = stringResource( - Res.string.the_group_signs_a_schema_so_it_takes_a_quorum - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - - if (!canSign) { - item { - Text( - text = stringResource( - Res.string.this_group_has_no_shared_key_to_sign_a_schema - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - } - - // Named rather than silent, and all of them at once: a - // button that does nothing for a schema it refuses is - // indistinguishable from a missed tap, and one reason per - // press would be a conversation. - if (editGroupCuratedSchemaViewModel.problems.isNotEmpty()) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.relatedGap - ) - ) { - editGroupCuratedSchemaViewModel.problems.forEach { problem -> - Text( - text = problem.message(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - } - - item { - SchemaTextField( - state = identifierFieldState, - icon = Icons.Default.Tag, - iconDescription = "Identifier of the list", - label = stringResource(Res.string.identifier), - placeholder = stringResource(Res.string.eg_bitcoin_mov), - // Locked on an edit: see the screen's own note. - readOnly = existing != null, - supportingText = if (existing != null) { - stringResource(Res.string.the_identifier_names_this_list_in_every_reply) - } else { - null - }, - isError = editGroupCuratedSchemaViewModel.problems.containsAny( - CuratedSchemaProblem.IdentifierMissing, - CuratedSchemaProblem.IdentifierTooLong - ) - ) - } - - item { - SchemaTextField( - state = titleFieldState, - icon = Icons.Default.Title, - iconDescription = "Title of the schema", - label = stringResource(Res.string.title), - placeholder = stringResource(Res.string.eg_bitcoin_mov_suggestion), - isError = editGroupCuratedSchemaViewModel.problems.containsAny( - CuratedSchemaProblem.TitleMissing, - CuratedSchemaProblem.TitleTooLong - ) - ) - } - - item { - SchemaTextField( - state = nameFieldState, - icon = Icons.AutoMirrored.Filled.Label, - iconDescription = "Name of the list", - label = stringResource(Res.string.name), - placeholder = stringResource(Res.string.eg_bitcoin_mov), - isError = editGroupCuratedSchemaViewModel.problems.containsAny( - CuratedSchemaProblem.NameMissing, - CuratedSchemaProblem.NameTooLong - ) - ) - } - - item { - SchemaTextField( - state = descriptionFieldState, - icon = Icons.AutoMirrored.Filled.Notes, - iconDescription = "What the list is for", - label = stringResource(Res.string.description), - placeholder = stringResource( - Res.string.eg_fields_for_a_bitcoin_mov_entry - ), - // The one identity field with prose in it, and the - // only one a single line would truncate while it - // was being typed. - lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), - isError = editGroupCuratedSchemaViewModel.problems.containsAny( - CuratedSchemaProblem.DescriptionMissing, - CuratedSchemaProblem.DescriptionTooLong - ) - ) - } - - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.relatedGap - ) - ) { - SectionHeading(stringResource(Res.string.visibility)) - - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - CuratedVisibility.entries.forEachIndexed { index, option -> - SegmentedButton( - selected = option == editGroupCuratedSchemaViewModel.visibility, - onClick = { - editGroupCuratedSchemaViewModel.selectVisibility(option) - }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = CuratedVisibility.entries.size - ), - label = { Text(stringResource(option.label())) } - ) - } - } - - // What the chosen word actually decides. Three - // words is three questions nobody can be expected - // to hold apart from the words alone. - Text( - text = stringResource( - editGroupCuratedSchemaViewModel.visibility.purpose() - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - item { - SchemaTextField( - state = pictureFieldState, - icon = Icons.Default.Image, - iconDescription = "Picture of the list", - label = stringResource(Res.string.picture_url), - placeholder = stringResource(Res.string.eg_https_example_com_group_png), - isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.PictureNotHttps) - ) - } - - item { - SchemaTextField( - state = domainFieldState, - icon = Icons.Default.Language, - iconDescription = "Domain of the list", - label = stringResource(Res.string.domain), - placeholder = stringResource(Res.string.eg_example_com), - supportingText = stringResource( - Res.string.a_domain_replaces_the_name_wherever_the_list_is_shown - ), - isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.DomainInvalid) - ) - } - - item { - SectionHeading(stringResource(Res.string.fields)) - } - - itemsIndexed(editGroupCuratedSchemaViewModel.fields) { index, field -> - SchemaFieldRow( - field = field, - isMandatory = editGroupCuratedSchemaViewModel.isMandatory(field), - onOpen = { editGroupCuratedSchemaViewModel.startEditingField(index) } - ) - } - - item { - TextButton(onClick = { editGroupCuratedSchemaViewModel.startAddingField() }) { - Icon(Icons.Default.Add, contentDescription = Decorative) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - - Text(stringResource(Res.string.add_field)) - } - } - - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.relatedGap - ) - ) { - SectionHeading(stringResource(Res.string.at_least_one_of)) - - Text( - text = stringResource( - Res.string.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - itemsIndexed(editGroupCuratedSchemaViewModel.requireAny) { index, group -> - RequireAnyRow( - fieldNames = editGroupCuratedSchemaViewModel.fields.map { it.name }, - selected = group, - onToggle = { fieldName -> - editGroupCuratedSchemaViewModel.toggleRequireAny(index, fieldName) - }, - onRemove = { editGroupCuratedSchemaViewModel.removeRequireAnyGroup(index) } - ) - } - - item { - TextButton( - onClick = { editGroupCuratedSchemaViewModel.addRequireAnyGroup() } - ) { - Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = Decorative) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - - Text(stringResource(Res.string.add_rule)) - } - } - - // Only where the visibility gives them something to be: on - // a public list anyone may suggest, and a list of people - // who may would say the opposite of what the word does. - if (editGroupCuratedSchemaViewModel.visibility != CuratedVisibility.Public) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.relatedGap - ) - ) { - SectionHeading(stringResource(Res.string.suggesters)) - - Text( - text = stringResource( - Res.string.pubkeys_besides_the_group_that_may_suggest_to_this_list - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - items(editGroupCuratedSchemaViewModel.suggesters) { pubkey -> - RemovableRow( - // The npub, because that is the form a member - // pasted it in and the form they would compare - // it against; whole and monospaced, so it can be. - text = pubkey.hexToNpubHrp(), - isMonospaced = true, - removeDescription = "Remove suggester", - onRemove = { editGroupCuratedSchemaViewModel.removeSuggester(pubkey) } - ) - } - - item { - AddRow( - state = suggesterFieldState, - icon = Icons.Default.PersonAdd, - label = stringResource(Res.string.npub_or_hex_pubkey), - placeholder = null, - addDescription = "Add suggester", - failure = when (editGroupCuratedSchemaViewModel.addSuggesterFailure) { - AddSuggesterFailure.NotAPubkey -> - stringResource(Res.string.that_is_not_a_pubkey) - AddSuggesterFailure.AlreadyListed -> - stringResource(Res.string.that_pubkey_is_already_a_suggester) - null -> null - }, - onAdd = { input -> editGroupCuratedSchemaViewModel.addSuggester(input) } - ) - } - } - - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.relatedGap - ) - ) { - SectionHeading(stringResource(Res.string.relays)) - - Text( - text = stringResource( - Res.string.where_suggestions_to_this_list_are_published - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - if (editGroupCuratedSchemaViewModel.relays.isEmpty()) { - Text( - text = stringResource( - Res.string.no_relays_named_so_a_client_will_pick_its_own - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - items(editGroupCuratedSchemaViewModel.relays) { url -> - RemovableRow( - text = url, - isMonospaced = false, - removeDescription = "Remove relay", - onRemove = { editGroupCuratedSchemaViewModel.removeRelay(url) } - ) - } - - item { - AddRow( - state = relayFieldState, - icon = Icons.Default.Dns, - label = stringResource(Res.string.relay_url), - placeholder = stringResource(Res.string.eg_wss_relay_example_com), - addDescription = "Add relay", - failure = when (editGroupCuratedSchemaViewModel.addRelayFailure) { - AddRelayFailure.NotARelay -> - stringResource(Res.string.that_is_not_a_relay_address) - AddRelayFailure.AlreadyListed -> - stringResource(Res.string.that_relay_is_already_in_this_list) - null -> null - }, - onAdd = { input -> editGroupCuratedSchemaViewModel.addRelay(input) } - ) - } - } - } - - editGroupCuratedSchemaViewModel.editingField?.let { edit -> - FieldEditorSheet( - edit = edit, - isMandatory = editGroupCuratedSchemaViewModel.isMandatory(edit.field), - onCommit = { field -> editGroupCuratedSchemaViewModel.commitField(field) }, - onRemove = { edit.index?.let(editGroupCuratedSchemaViewModel::removeField) }, - onDismiss = { editGroupCuratedSchemaViewModel.dismissFieldEditor() } - ) - } - } - - EditGroupCuratedSchemaUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - Text( - text = stringResource( - if (identifier == null) Res.string.add_schema else Res.string.edit_schema - ), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator(fillScreen = false) - } - } - } - } - - LaunchedEffect(true) { - if (initialEditGroupCuratedSchemaUIState == EditGroupCuratedSchemaUIState.Loading) { - editGroupCuratedSchemaViewModel.initiateEditGroupCuratedSchema() - } - } -} - -/** The word for who may suggest, for the group's screen and the editor alike. */ -internal fun CuratedVisibility.label(): StringResource = when (this) { - CuratedVisibility.Public -> Res.string.visibility_public - CuratedVisibility.Closed -> Res.string.visibility_closed - CuratedVisibility.Private -> Res.string.visibility_private -} - -/** The sentence saying what the word decides. */ -private fun CuratedVisibility.purpose(): StringResource = when (this) { - CuratedVisibility.Public -> Res.string.visibility_public_purpose - CuratedVisibility.Closed -> Res.string.visibility_closed_purpose - CuratedVisibility.Private -> Res.string.visibility_private_purpose -} - -/** - * The name of a field type, for the chips and the rows. - * - * A `when` rather than a field on each entry so that the enum stays what it is - * -- the NIP's nine words and their one rule each -- with no catalogue behind - * it, and so that a tenth type is a compile error here rather than a chip with - * no name. - */ -private fun CuratedFieldType.label(): StringResource = when (this) { - CuratedFieldType.Text -> Res.string.field_type_text - CuratedFieldType.LongText -> Res.string.field_type_longtext - CuratedFieldType.Token -> Res.string.field_type_token - CuratedFieldType.Url -> Res.string.field_type_url - CuratedFieldType.Image -> Res.string.field_type_image - CuratedFieldType.Enum -> Res.string.field_type_enum - CuratedFieldType.Year -> Res.string.field_type_year - CuratedFieldType.Duration -> Res.string.field_type_duration - CuratedFieldType.Number -> Res.string.field_type_number -} - -/** - * Why a schema cannot be proposed, said to the member. - * - * The caps are quoted from `CuratedSchemaEvent` rather than repeated, so the - * sentence and the check cannot disagree about the number. - */ -@Composable -internal fun CuratedSchemaProblem.message(): String = when (this) { - CuratedSchemaProblem.IdentifierMissing -> stringResource(Res.string.an_identifier_is_required) - CuratedSchemaProblem.IdentifierTooLong -> stringResource( - Res.string.the_identifier_must_be_at_most_n_characters, - CuratedSchemaEvent.MAX_IDENTIFIER.toString() - ) - CuratedSchemaProblem.TitleMissing -> stringResource(Res.string.a_title_is_required) - CuratedSchemaProblem.TitleTooLong -> stringResource( - Res.string.the_title_must_be_at_most_n_characters, - CuratedSchemaEvent.MAX_TITLE.toString() - ) - CuratedSchemaProblem.NameMissing -> stringResource(Res.string.a_name_is_required) - CuratedSchemaProblem.NameTooLong -> stringResource( - Res.string.the_name_must_be_at_most_n_characters, - CuratedSchemaEvent.MAX_NAME.toString() - ) - CuratedSchemaProblem.DescriptionMissing -> stringResource(Res.string.a_description_is_required) - CuratedSchemaProblem.DescriptionTooLong -> stringResource( - Res.string.the_description_must_be_at_most_n_characters, - CuratedSchemaEvent.MAX_DESCRIPTION.toString() - ) - CuratedSchemaProblem.VisibilityMissing -> stringResource(Res.string.say_who_may_suggest_to_this_list) - CuratedSchemaProblem.PictureNotHttps -> stringResource(Res.string.the_picture_must_be_an_https_url) - CuratedSchemaProblem.DomainInvalid -> stringResource(Res.string.that_is_not_a_domain_name) - CuratedSchemaProblem.NoFields -> stringResource(Res.string.a_schema_needs_at_least_one_field) - CuratedSchemaProblem.RelayInvalid -> stringResource(Res.string.one_of_the_relays_is_not_a_relay_address) -} - -/** Whether any of [problems] is about the field asking, for its error state. */ -private fun List.containsAny(vararg problems: CuratedSchemaProblem): Boolean = - problems.any { it in this } - -/** A heading over one part of the form, at the leading edge like the rows under it. */ -@Composable -private fun SectionHeading(text: String) { - Text( - modifier = Modifier.fillMaxWidth(), - text = text, - style = MaterialTheme.typography.titleSmall - ) -} - -/** - * One text field of the form, in the shape the app's other forms use. - * - * The borderless outlined field over the bottom bar's tint is four lines of - * colours apiece, and this screen has more of them than any other -- so the - * function, and the three things this one adds over `NostrProfileField`: a - * read-only state for the locked identifier, an error state for a field a - * problem is about, and a line of supporting text for a field that needs one. - */ -@Composable -private fun SchemaTextField( - state: TextFieldState, - icon: ImageVector, - iconDescription: String, - label: String, - placeholder: String, - lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine, - readOnly: Boolean = false, - isError: Boolean = false, - supportingText: String? = null -) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), - state = state, - lineLimits = lineLimits, - readOnly = readOnly, - isError = isError, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon(icon, contentDescription = iconDescription) - }, - label = { - Text(text = label) - }, - placeholder = { - Text(text = placeholder) - }, - supportingText = supportingText?.let { { Text(text = it) } } - ) -} - -/** - * One field of the schema, as a row that opens the sheet. - * - * The label is what a suggester will see over the input, so it is the headline; - * the name, type and requirement are what the schema says about it. A - * mandatory field is marked as required whatever its own flag says, since that - * is what will be signed. - */ -@Composable -private fun SchemaFieldRow( - field: CuratedField, - isMandatory: Boolean, - onOpen: () -> Unit -) { - Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) { - ListItem( - leadingContent = { - Icon(Icons.Default.Ballot, contentDescription = Decorative) - }, - headlineContent = { - Text( - text = field.labelOrName(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Text( - text = listOfNotNull( - field.name, - stringResource(field.type.label()), - stringResource( - if (field.isRequired || isMandatory) Res.string.required else Res.string.optional - ), - stringResource(Res.string.derived).takeIf { field.config.derived == true } - ).joinToString(" · "), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - } -} - -/** - * One `require-any` rule: a chip per field, selected for the ones in the rule. - * - * Chips over a picker because the whole point of a rule is which fields are in - * it, and a chip row shows that at a glance where a picker would show a count. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun RequireAnyRow( - fieldNames: List, - selected: List, - onToggle: (String) -> Unit, - onRemove: () -> Unit -) { - Card(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.compactPadding), - verticalAlignment = Alignment.CenterVertically - ) { - FlowRow( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - fieldNames.forEach { fieldName -> - FilterChip( - selected = fieldName in selected, - onClick = { onToggle(fieldName) }, - label = { Text(fieldName) } - ) - } - } - - IconButton(onClick = onRemove) { - Icon( - Icons.Default.Delete, - contentDescription = "Remove rule", - tint = MaterialTheme.colorScheme.error - ) - } - } - } -} - -/** One entry of a plain list -- a relay, a suggester -- with the way to drop it. */ -@Composable -private fun RemovableRow( - text: String, - isMonospaced: Boolean, - removeDescription: String, - onRemove: () -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.weight(1f), - text = text, - style = MaterialTheme.typography.bodyMedium, - fontFamily = if (isMonospaced) FontFamily.Monospace else null - ) - - IconButton(onClick = onRemove) { - Icon( - Icons.Default.Delete, - contentDescription = removeDescription, - tint = MaterialTheme.colorScheme.error - ) - } - } -} - -/** - * A field and a button that adds what is in it to a list, and the reason when - * it would not. - * - * [onAdd] answers whether the row appeared, which is the cue to clear the field - * -- a refused value stays where it can be corrected. - */ -@Composable -private fun AddRow( - state: TextFieldState, - icon: ImageVector, - label: String, - placeholder: String?, - addDescription: String, - failure: String?, - onAdd: (String) -> Boolean -) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - modifier = Modifier.weight(1f).background(BottomAppBarDefaults.containerColor), - state = state, - lineLimits = TextFieldLineLimits.SingleLine, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon(icon, contentDescription = Decorative) - }, - label = { Text(label) }, - placeholder = placeholder?.let { { Text(it) } }, - isError = failure != null - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) - - IconButton( - onClick = { - if (onAdd(state.text.toString())) { - state.clearText() - } - } - ) { - Icon(Icons.Default.Add, contentDescription = addDescription) - } - } - - failure?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } -} - -/** - * The sheet a field is edited on. - * - * Split into the sheet and its content the way `GroupKeyStateSheet` is, so the - * content can be rendered and measured on its own -- a bottom sheet is a popup - * with its own window, which a layout test cannot reach into. - */ -@Composable -private fun FieldEditorSheet( - edit: FieldEdit, - isMandatory: Boolean, - onCommit: (CuratedField) -> FieldProblem?, - onRemove: () -> Unit, - onDismiss: () -> Unit -) { - ModalBottomSheet(onDismiss = onDismiss, skipPartiallyExpanded = true) { - FieldEditorSheetContent( - edit = edit, - isMandatory = isMandatory, - onCommit = onCommit, - onRemove = onRemove - ) - } -} - -/** - * Everything a field can say about itself, and a button that puts it in the - * schema. - * - * The positional parts of the tag first -- name, label, placeholder, type, - * required -- because they are what a form needs; the config keys after, and - * only the ones the chosen type gives a meaning to. A minimum on a text field or - * options on a year would be written into the event and mean nothing, so they - * are not offered and, when the type changes away from them, not kept. - * - * **A mandatory field cannot stop being one.** Its tag is read-only and its - * requirement is on and disabled, because `CuratedSchema.normalized` would only - * put a field writing to `d` or `title` back if this let it be taken away -- so - * offering the change would offer something the event will not carry. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -internal fun FieldEditorSheetContent( - edit: FieldEdit, - isMandatory: Boolean, - onCommit: (CuratedField) -> FieldProblem?, - onRemove: () -> Unit -) { - val field = edit.field - - val nameFieldState = rememberTextFieldState(field.name) - val labelFieldState = rememberTextFieldState(field.label) - val placeholderFieldState = rememberTextFieldState(field.placeholder) - val tagFieldState = rememberTextFieldState(field.config.tag ?: "") - val markerFieldState = rememberTextFieldState(field.config.marker ?: "") - val maxFieldState = rememberTextFieldState(field.config.max?.toString() ?: "") - val minFieldState = rememberTextFieldState(field.config.min?.toString() ?: "") - val optionsFieldState = rememberTextFieldState(field.config.options?.joinToString("\n") ?: "") - val patternFieldState = rememberTextFieldState(field.config.pattern ?: "") - val hintFieldState = rememberTextFieldState(field.config.hint ?: "") - - var type by rememberSaveable { mutableStateOf(field.type) } - var isRequired by rememberSaveable { mutableStateOf(field.isRequired || isMandatory) } - var mayRepeat by rememberSaveable { mutableStateOf(field.config.repeat == true) } - var isHttpsOnly by rememberSaveable { mutableStateOf(field.config.https == true) } - var isDerived by rememberSaveable { mutableStateOf(field.config.derived == true) } - - var problem: FieldProblem? by remember { mutableStateOf(null) } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) - ) { - Text( - text = stringResource( - if (edit.index == null) Res.string.new_field else Res.string.edit_field - ), - style = MaterialTheme.typography.titleMedium - ) - - SchemaTextField( - state = nameFieldState, - icon = Icons.Default.Tag, - iconDescription = "Name of the field", - label = stringResource(Res.string.field_name), - placeholder = stringResource(Res.string.eg_watchurl), - isError = problem != null - ) - - // Named rather than silent, under the field it is about. - problem?.let { - Text( - text = when (it) { - FieldProblem.NameMissing -> stringResource(Res.string.a_field_needs_a_name) - FieldProblem.NameHasSpaces -> stringResource(Res.string.a_field_name_is_one_word) - FieldProblem.NameTaken -> stringResource(Res.string.a_field_named_that_already_exists) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - SchemaTextField( - state = labelFieldState, - icon = Icons.AutoMirrored.Filled.Label, - iconDescription = "Label over the input", - label = stringResource(Res.string.label), - placeholder = stringResource(Res.string.eg_watch_reference_url) - ) - - SchemaTextField( - state = placeholderFieldState, - icon = Icons.AutoMirrored.Filled.ShortText, - iconDescription = "Example shown in an empty input", - label = stringResource(Res.string.placeholder), - placeholder = stringResource(Res.string.eg_https_youtube_com_watch) - ) - - Text( - text = stringResource(Res.string.type), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - CuratedFieldType.entries.forEach { option -> - FilterChip( - selected = option == type, - onClick = { type = option }, - label = { Text(stringResource(option.label())) } - ) - } - } - - SwitchRow( - label = stringResource(Res.string.an_entry_must_have_this_field), - checked = isRequired, - enabled = !isMandatory, - onCheckedChange = { isRequired = it } - ) - - SchemaTextField( - state = tagFieldState, - icon = Icons.Default.Sell, - iconDescription = "Tag the field writes to", - label = stringResource(Res.string.writes_to_tag), - placeholder = nameFieldState.text.toString().ifBlank { stringResource(Res.string.eg_watchurl) }, - readOnly = isMandatory, - supportingText = stringResource( - Res.string.defaults_to_the_field_name_content_means_the_body - ) - ) - - SchemaTextField( - state = markerFieldState, - icon = Icons.Default.Bookmark, - iconDescription = "Marker on the tag", - label = stringResource(Res.string.marker), - placeholder = stringResource(Res.string.eg_watch), - supportingText = stringResource( - Res.string.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart - ) - ) - - SchemaTextField( - state = maxFieldState, - icon = Icons.Default.Numbers, - iconDescription = "Maximum", - label = stringResource( - if (type.isNumeric) Res.string.maximum_value else Res.string.maximum_characters - ), - placeholder = stringResource(if (type.isNumeric) Res.string.eg_2100 else Res.string.eg_200) - ) - - if (type.isNumeric) { - SchemaTextField( - state = minFieldState, - icon = Icons.Default.Numbers, - iconDescription = "Minimum", - label = stringResource(Res.string.minimum_value), - placeholder = stringResource(Res.string.eg_1900) - ) - } - - if (type == CuratedFieldType.Enum) { - SchemaTextField( - state = optionsFieldState, - icon = Icons.Default.Checklist, - iconDescription = "Allowed values", - label = stringResource(Res.string.options), - placeholder = stringResource(Res.string.eg_movie_documentary_short), - lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 6), - supportingText = stringResource(Res.string.one_per_line_or_separated_by_commas) - ) - } - - if (type == CuratedFieldType.Url) { - SwitchRow( - label = stringResource(Res.string.https_only), - checked = isHttpsOnly, - enabled = true, - onCheckedChange = { isHttpsOnly = it } - ) - } - - SwitchRow( - label = stringResource(Res.string.may_appear_more_than_once), - checked = mayRepeat, - enabled = true, - onCheckedChange = { mayRepeat = it } - ) - - SchemaTextField( - state = patternFieldState, - icon = Icons.AutoMirrored.Filled.Rule, - iconDescription = "Pattern the value must match", - label = stringResource(Res.string.pattern), - placeholder = stringResource(Res.string.eg_a_za_z_2_8), - supportingText = stringResource( - Res.string.a_regular_expression_the_whole_value_must_match - ) - ) - - SwitchRow( - label = stringResource(Res.string.filled_in_by_the_app_never_asked_for), - checked = isDerived, - enabled = true, - onCheckedChange = { isDerived = it } - ) - - SchemaTextField( - state = hintFieldState, - icon = Icons.Default.Lightbulb, - iconDescription = "Helper text", - label = stringResource(Res.string.hint), - placeholder = stringResource(Res.string.helper_text_under_the_input) - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - if (!isMandatory && edit.index != null) { - TextButton( - onClick = onRemove, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text(stringResource(Res.string.remove_field)) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - Button( - onClick = { - problem = onCommit( - CuratedField( - name = nameFieldState.text.toString().trim(), - type = type, - isRequired = isRequired || isMandatory, - placeholder = placeholderFieldState.text.toString().trim(), - label = labelFieldState.text.toString().trim(), - config = field.config.copy( - // Pinned for a mandatory field, so that renaming it - // cannot move it off the tag that makes it one. - tag = if (isMandatory) { - field.tag() - } else { - tagFieldState.text.toString().trim().ifEmpty { null } - }, - marker = markerFieldState.text.toString().trim().ifEmpty { null }, - max = maxFieldState.text.toString().trim().toLongOrNull(), - min = if (type.isNumeric) { - minFieldState.text.toString().trim().toLongOrNull() - } else { - null - }, - options = if (type == CuratedFieldType.Enum) { - optionsFieldState.text.toString() - .split('\n', ',') - .map { it.trim() } - .filter { it.isNotEmpty() } - .ifEmpty { null } - } else { - null - }, - https = if (type == CuratedFieldType.Url && isHttpsOnly) true else null, - repeat = if (mayRepeat) true else null, - pattern = patternFieldState.text.toString().trim().ifEmpty { null }, - derived = if (isDerived) true else null, - hint = hintFieldState.text.toString().trim().ifEmpty { null }, - ) - ) - ) - } - ) { - Text(stringResource(Res.string.done)) - } - } - } -} - -/** - * A labelled switch, the label taking the width and the switch its own target. - * - * The label is also the switch's description, so a screen reader announces what - * is being turned on rather than "switch, off" beside a sentence it has already - * read past. - */ -@Composable -private fun SwitchRow( - label: String, - checked: Boolean, - enabled: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.weight(1f), - text = label, - style = MaterialTheme.typography.bodyMedium - ) - - Switch( - modifier = Modifier.semantics { contentDescription = label }, - checked = checked, - enabled = enabled, - onCheckedChange = onCheckedChange - ) - } -} - -@ConformancePreviews -@Composable -private fun EditGroupCuratedSchemaScreenPreview() { - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - EditGroupCuratedSchemaScreen( - activeUserPublicKey = "", - chatRoomId = "publicKey", - relayHint = null, - identifier = null, - initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = "publicKey", - userPublicKey = "", - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ), - ), - // A new schema, so the editor seeds the two fields every list - // has and the preview shows what a group actually starts from - // rather than an empty form. - defaultRelays = listOf("wss://relay.example.com"), - // A group that can sign, so the form renders in the state a - // member actually meets it in rather than greyed out. - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt deleted file mode 100644 index 6914b722..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt +++ /dev/null @@ -1,427 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Notes -import androidx.compose.material.icons.filled.AlternateEmail -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Title -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.lifecycle.viewmodel.compose.viewModel -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.rememberNotifier -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.EditGroupNostrProfileViewModel -import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.about -import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_profile -import mantra.composeapp.generated.resources.edit_nostr_profile -import mantra.composeapp.generated.resources.eg_a_group_translating_hard_books -import mantra.composeapp.generated.resources.eg_group_example_com -import mantra.composeapp.generated.resources.eg_group_getalby_com -import mantra.composeapp.generated.resources.eg_https_example_com -import mantra.composeapp.generated.resources.eg_https_example_com_group_png -import mantra.composeapp.generated.resources.eg_translation_collective -import mantra.composeapp.generated.resources.lightning_address -import mantra.composeapp.generated.resources.name -import mantra.composeapp.generated.resources.nostr_address -import mantra.composeapp.generated.resources.picture_url -import mantra.composeapp.generated.resources.propose_nostr_profile -import mantra.composeapp.generated.resources.the_group_signs_its_profile_so_it_takes_a_quorum -import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_profile -import mantra.composeapp.generated.resources.website -import mantra.composeapp.generated.resources.what_the_group_says_about_itself_on_nostr -import org.jetbrains.compose.resources.stringResource - -/** - * The form for what a group says about itself on nostr. - * - * A group's kind:0 is signed by the group, so pressing the button proposes rather - * than saves: the form goes out as one event for the room's quorum to put its key - * to, and the screen hands over to the signing session it opened. Nothing about the - * profile has changed by the time this screen closes. - * - * The fields start at what the group already says, so an edit is an edit. And they - * are sent as they stand -- **an emptied field clears what the group had said**, - * which is the only way to unsay something a group has signed. Fields this form - * does not offer are carried across untouched; see `GroupNostrProfile.template`. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) -@Composable -fun EditGroupNostrProfileScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState = - EditGroupNostrProfileUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository, - onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val editGroupNostrProfileViewModel: EditGroupNostrProfileViewModel = viewModel( - factory = EditGroupNostrProfileViewModel.factory( - chatRoomId = chatRoomId, - relayHint = relayHint, - initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState, - activeUserPublicKey = activeUserPublicKey, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - // Read out here rather than in the click handler: both are composable and an - // onClick lambda is not. The scope is the caller's so the message survives this - // screen being replaced by the signing session. - val notify = rememberNotifier(rememberCoroutineScope()) - val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_profile) - - ScreenStateTransition(editGroupNostrProfileViewModel.editGroupNostrProfileUIState) { uiState -> - when (val editGroupNostrProfileUIState = uiState) { - is EditGroupNostrProfileUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, and - // reading it again with the same one fails the same way. - ErrorState( - message = editGroupNostrProfileUIState.message, - onRetry = null - ) - } - - is EditGroupNostrProfileUIState.Loaded -> { - val profile = editGroupNostrProfileUIState.groupNostrProfile - - // Seeded from the profile the group already signed, so the form is - // an edit of it. `rememberTextFieldState` saves what is typed, which - // is what survives a rotation with the edits intact. - val nameFieldState = rememberTextFieldState(profile?.name() ?: "") - val aboutFieldState = rememberTextFieldState(profile?.about() ?: "") - val pictureFieldState = rememberTextFieldState(profile?.picture() ?: "") - val websiteFieldState = rememberTextFieldState(profile?.website() ?: "") - val nip05FieldState = rememberTextFieldState(profile?.nip05() ?: "") - val lud16FieldState = rememberTextFieldState(profile?.lud16() ?: "") - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - val canSign = editGroupNostrProfileUIState.canSign - - // imePadding: this screen is nothing but text fields, and without it - // the software keyboard covers whichever one is being typed into. - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { - editGroupNostrProfileUIState.localChatRoom - .RenderChatRoomTitleText() - }, - navigationIcon = { NavigateBackButton(onNavigateBack) }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canSign) return@ExtendedFloatingActionButton - - editGroupNostrProfileViewModel.proposeNostrProfile( - localChatRoom = - editGroupNostrProfileUIState.localChatRoom, - groupNostrProfile = profile, - nameField = nameFieldState, - aboutField = aboutFieldState, - pictureField = pictureFieldState, - websiteField = websiteFieldState, - nip05Field = nip05FieldState, - lud16Field = lud16FieldState, - onSuccess = { sessionId -> - // Onto the session rather than back - // to the group. The profile has not - // changed yet -- it changes when a - // quorum signs -- so landing on a - // group still showing the old one - // would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - // Stay on the form with what was typed - // still in it. The proposal is what - // failed, and every word of it is worth - // keeping for the retry. - onFailure = { notify(couldNotPropose) } - ) - } - ) { - Icon( - Icons.Default.Draw, - contentDescription = "Propose nostr profile" - ) - Text(stringResource(Res.string.propose_nostr_profile)) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(Res.string.what_the_group_says_about_itself_on_nostr)) - - Text( - text = stringResource( - Res.string.the_group_signs_its_profile_so_it_takes_a_quorum - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - if (!canSign) { - Text( - text = stringResource( - Res.string.this_group_has_no_shared_key_to_sign_a_profile - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - - NostrProfileField( - state = nameFieldState, - icon = Icons.Default.Title, - iconDescription = "Name of the group", - label = stringResource(Res.string.name), - placeholder = stringResource(Res.string.eg_translation_collective) - ) - - NostrProfileField( - state = aboutFieldState, - icon = Icons.AutoMirrored.Filled.Notes, - iconDescription = "What the group is for", - label = stringResource(Res.string.about), - placeholder = stringResource( - Res.string.eg_a_group_translating_hard_books - ), - // The one field with prose in it, and the only one a - // single line would truncate while it was being typed. - lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 3) - ) - - NostrProfileField( - state = pictureFieldState, - icon = Icons.Default.Image, - iconDescription = "Picture of the group", - label = stringResource(Res.string.picture_url), - placeholder = stringResource(Res.string.eg_https_example_com_group_png) - ) - - NostrProfileField( - state = websiteFieldState, - icon = Icons.Default.Link, - iconDescription = "Website of the group", - label = stringResource(Res.string.website), - placeholder = stringResource(Res.string.eg_https_example_com) - ) - - NostrProfileField( - state = nip05FieldState, - icon = Icons.Default.AlternateEmail, - iconDescription = "Nostr address of the group", - label = stringResource(Res.string.nostr_address), - placeholder = stringResource(Res.string.eg_group_example_com) - ) - - NostrProfileField( - state = lud16FieldState, - icon = Icons.Default.Bolt, - iconDescription = "Lightning address of the group", - label = stringResource(Res.string.lightning_address), - placeholder = stringResource(Res.string.eg_group_getalby_com) - ) - } - } - } - - EditGroupNostrProfileUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - Text( - text = stringResource(Res.string.edit_nostr_profile), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator(fillScreen = false) - } - } - } - } - - LaunchedEffect(true) { - if (initialEditGroupNostrProfileUIState == EditGroupNostrProfileUIState.Loading) { - editGroupNostrProfileViewModel.initiateEditGroupNostrProfile() - } - } -} - -/** - * One field of the profile, in the shape the app's other forms use. - * - * Six of these on one screen is what makes it worth a function: the borderless - * outlined field over the bottom bar's tint is four lines of colours apiece, and - * six copies of that is where a divergence hides. - */ -@Composable -private fun NostrProfileField( - state: TextFieldState, - icon: ImageVector, - iconDescription: String, - label: String, - placeholder: String, - lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine -) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), - state = state, - lineLimits = lineLimits, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon(icon, contentDescription = iconDescription) - }, - label = { - Text(text = label) - }, - placeholder = { - Text(text = placeholder) - } - ) -} - -@ConformancePreviews -@Composable -private fun EditGroupNostrProfileScreenPreview() { - MantraTheme { - Surface( - modifier = Modifier.fillMaxSize() - ) { - EditGroupNostrProfileScreen( - activeUserPublicKey = "", - chatRoomId = "publicKey", - relayHint = null, - initialEditGroupNostrProfileUIState = EditGroupNostrProfileUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = "publicKey", - userPublicKey = "", - subject = "Translation collective", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ), - ), - // A group that can sign, so the form renders in the state a - // member actually meets it in rather than greyed out. - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt deleted file mode 100644 index 7b076c84..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt +++ /dev/null @@ -1,581 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.PrimaryScrollableTabRow -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.EmptyState -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.rememberNotifier -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.EditGroupRelaysViewModel -import press.mantra.compose.ui.view.state.EditGroupRelaysUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.add_relay -import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_these_relays -import mantra.composeapp.generated.resources.edit_relays -import mantra.composeapp.generated.resources.eg_wss_relay_example_com -import mantra.composeapp.generated.resources.no_relays_in_this_list -import mantra.composeapp.generated.resources.nothing_has_changed_to_propose -import mantra.composeapp.generated.resources.propose_relays -import mantra.composeapp.generated.resources.relay_read -import mantra.composeapp.generated.resources.relay_set_blocked -import mantra.composeapp.generated.resources.relay_set_blocked_purpose -import mantra.composeapp.generated.resources.relay_set_general -import mantra.composeapp.generated.resources.relay_set_general_purpose -import mantra.composeapp.generated.resources.relay_set_messages -import mantra.composeapp.generated.resources.relay_set_messages_purpose -import mantra.composeapp.generated.resources.relay_set_search -import mantra.composeapp.generated.resources.relay_set_search_purpose -import mantra.composeapp.generated.resources.relay_url -import mantra.composeapp.generated.resources.relay_write -import mantra.composeapp.generated.resources.relays_are_public_a_group_cannot_encrypt -import mantra.composeapp.generated.resources.that_is_not_a_relay_address -import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list -import mantra.composeapp.generated.resources.the_group_signs_each_list_so_it_takes_a_quorum -import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_relays -import mantra.composeapp.generated.resources.where_the_group_lives_on_nostr -import org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource - -/** - * The four relay lists a group keeps, edited as one sitting. - * - * A tab per list, a field to add one, a row per relay with a way to drop it, and -- - * in the one list NIP-65 gives markers to -- a read and a write chip. The shape is - * borrowed from Wisp's relay screen, which is the interface this app was asked to - * match, with one deliberate departure. - * - * **The button proposes rather than publishes.** Wisp signs with the user's own key - * and a tab's publish button is done the moment it is pressed. A group signs with a - * quorum, so what leaves this screen is a proposal, and the screen hands over to - * the session it opened. Nothing has changed until enough members sign. - * - * And it proposes **every list that changed at once**, rather than the tab in front - * of you. Four sessions for one sitting would ask a quorum the same question four - * times over what is plainly one decision -- see `EditGroupRelaysViewModel`, which - * carries the reasoning and works out what actually differs. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) -@Composable -fun EditGroupRelaysScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialEditGroupRelaysUIState: EditGroupRelaysUIState = EditGroupRelaysUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository, - onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val editGroupRelaysViewModel: EditGroupRelaysViewModel = viewModel( - factory = EditGroupRelaysViewModel.factory( - chatRoomId = chatRoomId, - relayHint = relayHint, - initialEditGroupRelaysUIState = initialEditGroupRelaysUIState, - activeUserPublicKey = activeUserPublicKey, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - // Read out here rather than in a click handler: both are composable and an - // onClick lambda is not. The scope is the caller's so a message survives this - // screen being replaced by the signing session. - val notify = rememberNotifier(rememberCoroutineScope()) - val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_these_relays) - val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose) - - ScreenStateTransition(editGroupRelaysViewModel.editGroupRelaysUIState) { uiState -> - when (val editGroupRelaysUIState = uiState) { - is EditGroupRelaysUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, and - // reading it again with the same one fails the same way. - ErrorState(message = editGroupRelaysUIState.message, onRetry = null) - } - - is EditGroupRelaysUIState.Loaded -> { - val urlFieldState = rememberTextFieldState() - val selectedSet = editGroupRelaysViewModel.selectedSet - val canSign = editGroupRelaysUIState.canSign - - // The editor's working copy comes from the state rather than from - // whatever loaded it, so a screen handed a loaded state -- a - // preview, a layout test -- fills in the same as the app does. - // Seeding leaves edits alone, so re-running this changes nothing. - LaunchedEffect(editGroupRelaysUIState.signed) { - editGroupRelaysViewModel.seedWorkingCopy(editGroupRelaysUIState.signed) - } - - // A refusal is about what is in the field, so it goes the moment - // the field changes rather than sitting under a URL that has since - // been corrected. - LaunchedEffect(urlFieldState) { - snapshotFlow { urlFieldState.text.toString() } - .collect { editGroupRelaysViewModel.clearAddFailure() } - } - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { - editGroupRelaysUIState.localChatRoom.RenderChatRoomTitleText() - }, - navigationIcon = { NavigateBackButton(onNavigateBack) }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canSign) return@ExtendedFloatingActionButton - - editGroupRelaysViewModel.proposeRelayLists( - localChatRoom = - editGroupRelaysUIState.localChatRoom, - signed = editGroupRelaysUIState.signed, - onSuccess = { sessionId -> - // Onto the session rather than back - // to the group. The lists have not - // changed yet -- they change when a - // quorum signs -- so landing on a - // group still showing the old ones - // would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - // Both stay on the form with the edits - // still in it. One is a question and - // one is a failure, and neither is a - // reason to throw away the typing. - onNothingToPropose = { notify(nothingChanged) }, - onFailure = { notify(couldNotPropose) } - ) - } - ) { - Icon( - Icons.Default.Draw, - contentDescription = "Propose relays" - ) - Text(stringResource(Res.string.propose_relays)) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { - PrimaryScrollableTabRow( - modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), - edgePadding = 10.dp, - selectedTabIndex = selectedSet.ordinal, - ) { - GroupRelaySet.entries.forEach { set -> - Tab( - selected = set == selectedSet, - onClick = { editGroupRelaysViewModel.selectSet(set) }, - text = { Text(text = stringResource(set.label())) } - ) - } - } - - Column( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = MaterialTheme.spacing.screenMargin), - verticalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.itemGap - ), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(Res.string.where_the_group_lives_on_nostr), - style = MaterialTheme.typography.titleSmall - ) - - // What this tab's list is actually for. Four relay lists - // is four questions nobody can be expected to hold apart - // from their names alone. - Text( - text = stringResource(selectedSet.purpose()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource( - Res.string.the_group_signs_each_list_so_it_takes_a_quorum - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - if (selectedSet == GroupRelaySet.Blocked) { - // Said where it matters. A person's client keeps - // this list encrypted, and somebody blocking a relay - // here should know the group cannot. - Text( - text = stringResource( - Res.string.relays_are_public_a_group_cannot_encrypt - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - - if (!canSign) { - Text( - text = stringResource( - Res.string.this_group_has_no_shared_key_to_sign_relays - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - modifier = Modifier.weight(1f) - .background(BottomAppBarDefaults.containerColor), - state = urlFieldState, - lineLimits = TextFieldLineLimits.SingleLine, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon(Icons.Default.Dns, contentDescription = Decorative) - }, - label = { Text(stringResource(Res.string.relay_url)) }, - placeholder = { - Text(stringResource(Res.string.eg_wss_relay_example_com)) - }, - isError = editGroupRelaysViewModel.addFailure != null - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) - - IconButton( - onClick = { - if ( - editGroupRelaysViewModel.addRelay( - urlFieldState.text.toString() - ) - ) { - urlFieldState.clearText() - } - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Add relay" - ) - } - } - - // Named rather than silent. An add button that does - // nothing for a URL it refuses is indistinguishable from - // a missed tap. - editGroupRelaysViewModel.addFailure?.let { failure -> - Text( - text = when (failure) { - EditGroupRelaysViewModel.AddFailure.NotARelay -> - stringResource(Res.string.that_is_not_a_relay_address) - EditGroupRelaysViewModel.AddFailure.AlreadyListed -> - stringResource( - Res.string.that_relay_is_already_in_this_list - ) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - } - - val relays = editGroupRelaysViewModel.relaysOf(selectedSet) - - if (relays.isEmpty()) { - EmptyState( - message = stringResource(Res.string.no_relays_in_this_list), - icon = Icons.Default.Dns - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = MaterialTheme.spacing.screenMargin) - ) { - items(items = relays, key = { it.url.url }) { relay -> - RelayRow( - relay = relay, - hasReadWriteMarkers = selectedSet.hasReadWriteMarkers, - onToggleRead = { - editGroupRelaysViewModel - .toggleRead(selectedSet, relay) - }, - onToggleWrite = { - editGroupRelaysViewModel - .toggleWrite(selectedSet, relay) - }, - onRemove = { - editGroupRelaysViewModel - .removeRelay(selectedSet, relay) - } - ) - } - } - } - } - } - } - - EditGroupRelaysUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - Text( - text = stringResource(Res.string.edit_relays), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator(fillScreen = false) - } - } - } - } - - LaunchedEffect(true) { - if (initialEditGroupRelaysUIState == EditGroupRelaysUIState.Loading) { - editGroupRelaysViewModel.initiateEditGroupRelays() - } - } -} - -/** - * The tab's name, and the sentence saying what its list decides. - * - * Kept here rather than on [GroupRelaySet] so the enum stays what it is -- four - * nostr kinds and how to read and write them -- with no catalogue behind it. A - * `when` rather than a field on each entry, so adding a set is a compile error here - * rather than a tab with no name. - */ -private fun GroupRelaySet.label(): StringResource = when (this) { - GroupRelaySet.General -> Res.string.relay_set_general - GroupRelaySet.Messages -> Res.string.relay_set_messages - GroupRelaySet.Search -> Res.string.relay_set_search - GroupRelaySet.Blocked -> Res.string.relay_set_blocked -} - -private fun GroupRelaySet.purpose(): StringResource = when (this) { - GroupRelaySet.General -> Res.string.relay_set_general_purpose - GroupRelaySet.Messages -> Res.string.relay_set_messages_purpose - GroupRelaySet.Search -> Res.string.relay_set_search_purpose - GroupRelaySet.Blocked -> Res.string.relay_set_blocked_purpose -} - -/** - * One relay of one list. - * - * The read and write chips only in [hasReadWriteMarkers], which is NIP-65 alone -- - * the other three kinds have a relay in the list or not, and a chip there would - * offer a distinction the event cannot carry. - */ -@Composable -private fun RelayRow( - relay: GroupRelay, - hasReadWriteMarkers: Boolean, - onToggleRead: () -> Unit, - onToggleWrite: () -> Unit, - onRemove: () -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap), - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - // The host rather than the whole URL: every relay here starts - // `wss://` -- nothing else is allowed in -- so the scheme is four - // characters of nothing in front of the part that differs. - text = relay.displayUrl(), - style = MaterialTheme.typography.bodyMedium - ) - - if (hasReadWriteMarkers) { - Row( - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) - ) { - FilterChip( - selected = relay.read, - onClick = onToggleRead, - label = { Text(stringResource(Res.string.relay_read)) } - ) - FilterChip( - selected = relay.write, - onClick = onToggleWrite, - label = { Text(stringResource(Res.string.relay_write)) } - ) - } - } - } - - IconButton(onClick = onRemove) { - Icon( - Icons.Default.Delete, - contentDescription = "Remove relay", - tint = MaterialTheme.colorScheme.error - ) - } - } -} - -@ConformancePreviews -@Composable -private fun EditGroupRelaysScreenPreview() { - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - EditGroupRelaysScreen( - activeUserPublicKey = "", - chatRoomId = "publicKey", - relayHint = null, - initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = "publicKey", - userPublicKey = "", - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ), - ), - // Unsigned lists, which is a group opening this for the first - // time -- so the editor fills in from `GroupRelaySet.defaults` - // and the preview shows the relay a new group actually starts - // with rather than an empty tab. - signed = GroupRelayList.allAmong(emptyList(), "publicKey"), - // A group that can sign, so the form renders in the state a - // member actually meets it in rather than greyed out. - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt deleted file mode 100644 index 7edd477e..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreen.kt +++ /dev/null @@ -1,365 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.curated_entries -import mantra.composeapp.generated.resources.in_list -import mantra.composeapp.generated.resources.signed_by_the_group_on -import mantra.composeapp.generated.resources.suggested_by -import mantra.composeapp.generated.resources.this_group_has_not_accepted_anything_into_a_list_yet -import org.jetbrains.compose.resources.stringResource -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.shortened -import press.mantra.compose.extensions.toFormattedTimeAndDateString -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.EmptyState -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.GroupCuratedEntriesViewModel -import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState -import kotlin.time.Instant - -/** - * The entries the group has accepted into the lists it curates, newest first. - * - * Pushed from its row on the group's screen, which counts the entries and - * nothing more. This is where they are read: one card per entry with a - * broadcast under it. No gate on either, because reading what the group - * decided takes no share of the key, and neither does repeating its signature - * to a relay. - * - * There is no way in to add one here, because an entry is accepted from the - * queue it was suggested to, which is reached from its schema's card on the - * `GroupCuratedSchemasScreen`. The empty state says as much. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GroupCuratedEntriesScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState = - GroupCuratedEntriesUIState.Loading, - chatRepository: ChatRepository, - onNavigateToRoute: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val groupCuratedEntriesViewModel: GroupCuratedEntriesViewModel = viewModel( - factory = GroupCuratedEntriesViewModel.factory( - chatRoomId = chatRoomId, - initialGroupCuratedEntriesUIState = initialGroupCuratedEntriesUIState, - chatRepository = chatRepository, - ) - ) - - LaunchedEffect(true) { - if (initialGroupCuratedEntriesUIState == GroupCuratedEntriesUIState.Loading) { - groupCuratedEntriesViewModel.initiate() - } - } - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.curated_entries), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { NavigateBackButton(onNavigateBack) } - ) - } - ) { padding -> - ScreenStateTransition(groupCuratedEntriesViewModel.groupCuratedEntriesUIState) { uiState -> - when (val state = uiState) { - is GroupCuratedEntriesUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, - // and reading it again with the same one fails the same way. - ErrorState( - modifier = Modifier.padding(padding), - message = state.message, - onRetry = null - ) - } - - GroupCuratedEntriesUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(padding) - .padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - LoadingDataIndicator(fillScreen = false) - } - } - - is GroupCuratedEntriesUIState.Loaded -> { - LazyColumn( - modifier = Modifier.padding(padding).readableContent().fillMaxSize() - .padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (state.groupCuratedEntries.isEmpty()) { - item { - EmptyState( - message = stringResource( - Res.string.this_group_has_not_accepted_anything_into_a_list_yet - ) - ) - } - } else { - items( - items = state.groupCuratedEntries, - key = { accepted -> accepted.signedEvent.id } - ) { accepted -> - // The card and its broadcast as one item, so the - // button stays under the entry it sends however - // the list is arranged. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupCuratedEntryCard(accepted = accepted) - - BroadcastButton( - onClick = { - onNavigateToRoute.invoke( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - signedEventId = accepted.signedEvent.id - ) - ) - } - ) - } - } - } - } - } - } - } - } -} - -/** - * One entry the group accepted into a list it curates, as the list's own word. - * - * The title first, because every list has one; then which list, since a group - * may curate several and an entry says nothing about its list on its face; - * then the glance the queue row gives -- the fields that say what the entry - * *is*, labelled as the schema labels them, and not the links -- and the body - * where the list has one. The last line credits the suggester, when there was - * one, and dates the group's decision, which is the fact a member reading the - * screen came for. - * - * Not a way in to anything: the entry is revised from the queue it was - * suggested to, where the suggestion it came from and the "Curated" mark sit - * side by side, and the card's one action is the broadcast under it. - */ -@Composable -private fun GroupCuratedEntryCard(accepted: GroupCuratedEntry) { - val schema = accepted.list.schema - val entry = accepted.entry - - Card(modifier = Modifier.fillMaxWidth()) { - ListItem( - leadingContent = { - Icon( - Icons.Default.CheckCircle, - contentDescription = Decorative, - tint = MaterialTheme.colorScheme.primary - ) - }, - headlineContent = { - Text( - text = accepted.title, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - text = stringResource(Res.string.in_list, schema.displayName()), - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - glanceOf(entry, schema)?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - - bodyOf(entry, schema)?.let { - Text( - text = it, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = listOfNotNull( - entry.source?.let { stringResource(Res.string.suggested_by, it.pubKey.shortened()) }, - stringResource( - Res.string.signed_by_the_group_on, - accepted.signedAt.toFormattedTimeAndDateString() - ) - ).joinToString(" · "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - } -} - -@ConformancePreviews -@Composable -private fun GroupCuratedEntriesScreenPreview() { - val chatRoomId = "d".repeat(64) - - // Built rather than read out of signed events: the reading checks a real - // signature, and there is no real one here. - val films = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - schema = CuratedSchema( - identifier = "films", - title = "Films worth translating suggestion", - name = "Films worth translating", - description = "Things the group would translate, with a link to each.", - visibility = CuratedVisibility.Public, - fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), - relays = listOf("wss://relay.one.example") - ) - ) - - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - GroupCuratedEntriesScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - initialGroupCuratedEntriesUIState = GroupCuratedEntriesUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - groupCuratedEntries = listOf( - GroupCuratedEntry( - signedEvent = GroupSignedEvent( - id = "e".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedEntryEvent.CANONICAL_KIND, - tags = arrayOf( - arrayOf("d", "magic-money"), - arrayOf("title", "Magic Money") - ), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_900) - ), - list = films, - entry = CuratedEntry( - kind = CuratedEntryEvent.CANONICAL_KIND, - id = "e".repeat(64), - pubKey = chatRoomId, - createdAt = 1_700_000_900, - identifier = "magic-money", - title = "Magic Money", - values = mapOf( - "identifier" to listOf("magic-money"), - "title" to listOf("Magic Money"), - ), - source = null - ) - ) - ) - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt deleted file mode 100644 index 5d2f74e7..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreen.kt +++ /dev/null @@ -1,442 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.PlaylistAdd -import androidx.compose.material.icons.filled.Ballot -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.add_schema -import mantra.composeapp.generated.resources.curated_schemas -import mantra.composeapp.generated.resources.fields_colon -import mantra.composeapp.generated.resources.signed_by_the_group_on -import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet -import mantra.composeapp.generated.resources.view_suggestions -import org.jetbrains.compose.resources.stringResource -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.toFormattedTimeAndDateString -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.EmptyState -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.GroupCuratedSchemasViewModel -import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState -import kotlin.time.Instant - -/** - * The lists the group curates, as the schemas it signed for them. - * - * Pushed from its row on the group's screen, which counts the schemas and - * nothing more. This is where they are read: one card per list, most recently - * signed first, each with its queue and its broadcast under it, and -- for a - * member holding a share of the key -- the way into the editor, on the card - * for a revision and at the end for a new list. - * - * A schema exists to be read by people who were never in the room, so a member - * without a share reads it like anybody else, and reads its queue, which the - * group did not write. What they cannot do is propose a revision -- neither a - * new schema nor an edit -- since proposing is a signature by the group and - * `proposeSigningBatch` throws for a device holding no share. - */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun GroupCuratedSchemasScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState = - GroupCuratedSchemasUIState.Loading, - chatRepository: ChatRepository, - onNavigateToRoute: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val groupCuratedSchemasViewModel: GroupCuratedSchemasViewModel = viewModel( - factory = GroupCuratedSchemasViewModel.factory( - chatRoomId = chatRoomId, - initialGroupCuratedSchemasUIState = initialGroupCuratedSchemasUIState, - chatRepository = chatRepository, - ) - ) - - LaunchedEffect(true) { - if (initialGroupCuratedSchemasUIState == GroupCuratedSchemasUIState.Loading) { - groupCuratedSchemasViewModel.initiate() - } - } - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.curated_schemas), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { NavigateBackButton(onNavigateBack) } - ) - } - ) { padding -> - ScreenStateTransition(groupCuratedSchemasViewModel.groupCuratedSchemasUIState) { uiState -> - when (val state = uiState) { - is GroupCuratedSchemasUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, - // and reading it again with the same one fails the same way. - ErrorState( - modifier = Modifier.padding(padding), - message = state.message, - onRetry = null - ) - } - - GroupCuratedSchemasUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(padding) - .padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - LoadingDataIndicator(fillScreen = false) - } - } - - is GroupCuratedSchemasUIState.Loaded -> { - // The one way into the editor, for a revision and for a new - // list alike: the editor takes the identifier of the schema - // to revise, and null for one that does not exist yet. - val edit: (String?) -> Unit = { identifier -> - onNavigateToRoute.invoke( - EditGroupCuratedSchemaRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = identifier - ) - ) - } - - LazyColumn( - modifier = Modifier.padding(padding).readableContent().fillMaxSize() - .padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (state.groupCuratedSchemas.isEmpty()) { - item { - EmptyState( - message = stringResource( - Res.string.this_group_has_not_published_a_curated_schema_yet - ), - action = if (state.canSign) { - { AddSchemaButton(onClick = { edit(null) }) } - } else { - null - } - ) - } - } else { - items( - items = state.groupCuratedSchemas, - key = { curated -> curated.signedEvent.id } - ) { curated -> - // The card and its queue as one item, so the - // button stays under the schema it is about - // however the list is arranged. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupCuratedSchemaCard( - curatedSchema = curated, - // Into the editor for a member who could - // propose a revision, and a plain card for - // one who could not: the screen's gate, - // applied to the card because each schema - // is edited on its own. - onEdit = if (state.canSign) { - { edit(curated.identifier) } - } else { - null - } - ) - - // Under every schema and behind no gate, - // because the queue is the one thing here - // the group did not write: a schema exists - // to be answered by strangers, and reading - // the answers takes no share of the key. - // Per card because each list has its own - // queue. - // - // Beside the broadcast button rather than - // over it, wrapping where the two will not - // fit on one line: both are about this one - // card and a column of buttons under every - // schema would read as a menu. - FlowRow( - horizontalArrangement = Arrangement.spacedBy( - MaterialTheme.spacing.itemGap, - Alignment.CenterHorizontally - ) - ) { - TextButton( - onClick = { - onNavigateToRoute.invoke( - CuratedSuggestionListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = curated.identifier - ) - ) - } - ) { - Icon(Icons.Default.Inbox, contentDescription = Decorative) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.view_suggestions)) - } - - BroadcastButton( - onClick = { - onNavigateToRoute.invoke( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - signedEventId = curated.signedEvent.id - ) - ) - } - ) - } - } - } - - if (state.canSign) { - item { - AddSchemaButton(onClick = { edit(null) }) - } - } - } - } - } - } - } - } -} - -/** - * One list the group curates, as the schema it signed for it. - * - * Named by the list -- the domain where it claims one, else the name -- because - * that is what a suggester sees the list called; the title under it is the label - * the event carries. The fields are listed by label rather than counted, since - * what a reader wants to know about a list is what an entry in it asks for, and - * "13 fields" answers a different question. - * - * The visibility is on the last line beside the date, because who may suggest is - * the one fact on the card a member might act on -- a closed list is one they - * would have to be named on. - * - * [onEdit] null draws a card that cannot be opened. It is the screen's gate - * applied per card: a schema is edited on its own, so the way in has to be on it. - */ -@Composable -private fun GroupCuratedSchemaCard( - curatedSchema: GroupCuratedSchema, - onEdit: (() -> Unit)? -) { - val content: @Composable () -> Unit = { - ListItem( - leadingContent = { - Icon(Icons.Default.Ballot, contentDescription = Decorative) - }, - trailingContent = onEdit?.let { - { Icon(Icons.Default.Edit, contentDescription = "Edit schema") } - }, - headlineContent = { - Text( - text = curatedSchema.schema.displayName(), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - text = curatedSchema.schema.title, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = curatedSchema.schema.description, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = stringResource( - Res.string.fields_colon, - curatedSchema.schema.fields.joinToString { it.labelOrName() } - ), - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = listOfNotNull( - curatedSchema.schema.visibility?.let { stringResource(it.label()) }, - stringResource( - Res.string.signed_by_the_group_on, - curatedSchema.signedAt.toFormattedTimeAndDateString() - ) - ).joinToString(" · "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - } - - if (onEdit != null) { - Card(modifier = Modifier.fillMaxWidth(), onClick = onEdit) { content() } - } else { - Card(modifier = Modifier.fillMaxWidth()) { content() } - } -} - -/** - * The way into `EditGroupCuratedSchemaScreen` for a list that does not exist - * yet. The icon is decorative because the label is beside it. - */ -@Composable -private fun AddSchemaButton(onClick: () -> Unit) { - TextButton(onClick = onClick) { - Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = Decorative) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - - Text(stringResource(Res.string.add_schema)) - } -} - -@ConformancePreviews -@Composable -private fun GroupCuratedSchemasScreenPreview() { - val chatRoomId = "d".repeat(64) - - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - GroupCuratedSchemasScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - initialGroupCuratedSchemasUIState = GroupCuratedSchemasUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - // Built rather than read out of a signed event: the reading - // checks a real signature, and there is no real one here. - groupCuratedSchemas = listOf( - GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - schema = CuratedSchema( - identifier = "films", - title = "Films worth translating suggestion", - name = "Films worth translating", - description = "Things the group would translate, with a link to each.", - visibility = CuratedVisibility.Public, - fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), - relays = listOf("wss://relay.one.example") - ) - ) - ), - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt deleted file mode 100644 index f60b7414..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreen.kt +++ /dev/null @@ -1,353 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Badge -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.edit_nostr_profile -import mantra.composeapp.generated.resources.no_name_on_this_profile -import mantra.composeapp.generated.resources.nostr_profile -import mantra.composeapp.generated.resources.signed_by_the_group_on -import mantra.composeapp.generated.resources.this_group_has_not_said_anything_about_itself -import org.jetbrains.compose.resources.stringResource -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.toFormattedTimeAndDateString -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.EmptyState -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton -import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.GroupNostrProfileViewModel -import press.mantra.compose.ui.view.state.GroupNostrProfileUIState -import kotlin.time.Instant - -/** - * What the group says about itself on nostr, as the group signed it. - * - * Pushed from its row on the group's screen, which names the profile and - * nothing more. This is where it is read: the card, the broadcast under it, - * and -- for a member holding a share of the key -- the way into the editor. - * A group that has said nothing is told so, and offered the editor from the - * same place, since describing itself is the one thing to do about that. - * - * The profile is worth reading either way; proposing one is a signature by the - * group, and `proposeSigningBatch` throws for a device holding no share of the - * key. The editor is hidden rather than disabled, the way every gated way in - * on the group's screen is. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GroupNostrProfileScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialGroupNostrProfileUIState: GroupNostrProfileUIState = GroupNostrProfileUIState.Loading, - chatRepository: ChatRepository, - onNavigateToRoute: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val groupNostrProfileViewModel: GroupNostrProfileViewModel = viewModel( - factory = GroupNostrProfileViewModel.factory( - chatRoomId = chatRoomId, - initialGroupNostrProfileUIState = initialGroupNostrProfileUIState, - chatRepository = chatRepository, - ) - ) - - LaunchedEffect(true) { - if (initialGroupNostrProfileUIState == GroupNostrProfileUIState.Loading) { - groupNostrProfileViewModel.initiate() - } - } - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.nostr_profile), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { NavigateBackButton(onNavigateBack) } - ) - } - ) { padding -> - ScreenStateTransition(groupNostrProfileViewModel.groupNostrProfileUIState) { uiState -> - when (val state = uiState) { - is GroupNostrProfileUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, - // and reading it again with the same one fails the same way. - ErrorState( - modifier = Modifier.padding(padding), - message = state.message, - onRetry = null - ) - } - - GroupNostrProfileUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(padding) - .padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - LoadingDataIndicator(fillScreen = false) - } - } - - is GroupNostrProfileUIState.Loaded -> { - val edit: () -> Unit = { - onNavigateToRoute.invoke( - EditGroupNostrProfileRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - - LazyColumn( - modifier = Modifier.padding(padding).readableContent().fillMaxSize() - .padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - item { - state.groupNostrProfile?.let { groupNostrProfile -> - // The card and its broadcast button as one item, - // so the button stays under the event it sends - // however the list is arranged. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupNostrProfileCard( - groupNostrProfile = groupNostrProfile, - publicKey = chatRoomId - ) - - BroadcastButton( - onClick = { - onNavigateToRoute.invoke( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - signedEventId = groupNostrProfile.signedEvent.id - ) - ) - } - ) - } - } ?: EmptyState( - message = stringResource( - Res.string.this_group_has_not_said_anything_about_itself - ), - // The way to fill the absence, where this device - // could: describing the group is a signature by it. - action = if (state.canSign) { - { EditNostrProfileButton(onClick = edit) } - } else { - null - } - ) - } - - if (state.canSign && state.groupNostrProfile != null) { - item { - EditNostrProfileButton(onClick = edit) - } - } - } - } - } - } - } -} - -/** - * What the group says about itself on nostr, as the group signed it. - * - * A preview rather than a profile screen: the name, the picture, the nostr address - * and the first lines of the group's own account of itself, which is what a member - * opening the group's details came here to check. The whole thing is one signed - * event and the proposal list is where it can be read out in full. - * - * **The picture and the name come from the metadata; the tint comes from the key.** - * [publicKey] is the room's id, which is the pubkey the group signs as, so a group - * with no picture yet still gets the colour it has everywhere else in the app. - * - * The signing date is here because a group's profile is standing state with no - * other clue to its age -- and because the alternative reading, that this arrived - * from a relay a moment ago, is exactly what has not happened. Nothing on this card - * was published by being signed -- see `GroupNostrProfile` -- and the broadcast - * button under it is how it gets to a relay. - */ -@Composable -private fun GroupNostrProfileCard( - groupNostrProfile: GroupNostrProfile, - publicKey: HexKey -) { - Card(modifier = Modifier.fillMaxWidth()) { - ListItem( - leadingContent = { - ProfileAvatar( - publicKey = publicKey, - picture = groupNostrProfile.picture(), - name = groupNostrProfile.name() - ) - }, - headlineContent = { - Text( - // Named rather than falling back to the pubkey: the signing key - // row on the group's screen already shows that, and showing it - // twice would say the group has a name when it has not. - text = groupNostrProfile.name() - ?: stringResource(Res.string.no_name_on_this_profile), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Column( - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - groupNostrProfile.nip05()?.let { - Text( - text = it, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - groupNostrProfile.about()?.let { - Text( - text = it, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = stringResource( - Res.string.signed_by_the_group_on, - groupNostrProfile.signedAt.toFormattedTimeAndDateString() - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - } -} - -/** The way into `EditGroupNostrProfileScreen`. The icon is decorative because the label is beside it. */ -@Composable -private fun EditNostrProfileButton(onClick: () -> Unit) { - TextButton(onClick = onClick) { - Icon(Icons.Default.Badge, contentDescription = Decorative) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - - Text(stringResource(Res.string.edit_nostr_profile)) - } -} - -@ConformancePreviews -@Composable -private fun GroupNostrProfileScreenPreview() { - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - GroupNostrProfileScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = "d".repeat(64), - relayHint = null, - initialGroupNostrProfileUIState = GroupNostrProfileUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = "d".repeat(64), - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - // Built directly rather than read out of a signed event: the - // reading checks a signature, and there is no real one here. - groupNostrProfile = GroupNostrProfile( - signedEvent = GroupSignedEvent( - id = "e".repeat(64), - chatRoomId = "d".repeat(64), - publicKey = "d".repeat(64), - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - metadata = UserMetadata().apply { - displayName = "Translation collective" - about = "A group translating hard books into Sesotho." - nip05 = "group@example.com" - } - ), - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt deleted file mode 100644 index bac3ebd6..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupPostsScreen.kt +++ /dev/null @@ -1,352 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.a_reply -import mantra.composeapp.generated.resources.add_post -import mantra.composeapp.generated.resources.posted_on -import mantra.composeapp.generated.resources.posts -import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet -import org.jetbrains.compose.resources.stringResource -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.inComparableGroups -import press.mantra.compose.extensions.toFormattedTimeAndDateString -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.EmptyState -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton -import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.GroupPostsViewModel -import press.mantra.compose.ui.view.state.GroupPostsUIState -import kotlin.time.Instant - -/** - * What the group has said publicly, as itself, newest first. - * - * Pushed from its row on the group's screen, which counts the posts and nothing - * more. This is where they are read: one card per post with a broadcast under - * it, and -- for a member holding a share of the key -- the way into the - * composer. A group that has said nothing is told so, and offered the composer - * from the same place. - * - * A post is the one thing the group signs that is meant to be *read* by people - * who were never in the room, so a member without a share reads it like - * anybody else and simply cannot propose the next one: proposing is a signature - * by the group, and `proposeSigningBatch` throws for a device holding no share. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GroupPostsScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialGroupPostsUIState: GroupPostsUIState = GroupPostsUIState.Loading, - chatRepository: ChatRepository, - onNavigateToRoute: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val groupPostsViewModel: GroupPostsViewModel = viewModel( - factory = GroupPostsViewModel.factory( - chatRoomId = chatRoomId, - initialGroupPostsUIState = initialGroupPostsUIState, - chatRepository = chatRepository, - ) - ) - - LaunchedEffect(true) { - if (initialGroupPostsUIState == GroupPostsUIState.Loading) { - groupPostsViewModel.initiate() - } - } - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.posts), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { NavigateBackButton(onNavigateBack) } - ) - } - ) { padding -> - ScreenStateTransition(groupPostsViewModel.groupPostsUIState) { uiState -> - when (val state = uiState) { - is GroupPostsUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, - // and reading it again with the same one fails the same way. - ErrorState( - modifier = Modifier.padding(padding), - message = state.message, - onRetry = null - ) - } - - GroupPostsUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(padding) - .padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - LoadingDataIndicator(fillScreen = false) - } - } - - is GroupPostsUIState.Loaded -> { - val add: () -> Unit = { - onNavigateToRoute.invoke( - AddGroupPostRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - - LazyColumn( - modifier = Modifier.padding(padding).readableContent().fillMaxSize() - .padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (state.groupPosts.isEmpty()) { - item { - EmptyState( - message = stringResource( - Res.string.this_group_has_not_posted_anything_yet - ), - action = if (state.canSign) { - { AddPostButton(onClick = add) } - } else { - null - } - ) - } - } else { - items( - items = state.groupPosts, - key = { post -> post.signedEvent.id } - ) { post -> - // The card and its broadcast button as one item, - // so the button stays under the event it sends - // however the list is arranged. - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - GroupPostCard( - post = post, - publicKey = chatRoomId, - groupNostrProfile = state.groupNostrProfile - ) - - BroadcastButton( - onClick = { - onNavigateToRoute.invoke( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - signedEventId = post.signedEvent.id - ) - ) - } - ) - } - } - - if (state.canSign) { - item { - AddPostButton(onClick = add) - } - } - } - } - } - } - } - } -} - -/** - * One thing the group said, as the group signed it. - * - * The whole note rather than a preview. A post is short by construction -- kind:1 - * with no long-form arm behind it -- and truncating the one kind of content on this - * screen that exists to be *read* would mean opening something to see a paragraph. - * - * **The byline is the group's**, taken from its profile where it has one and from - * the room's own id where it has not. It is worth spelling out because a post is - * the one thing here a reader might otherwise attribute to whichever member - * proposed it -- and the point of signing it as the group is that they cannot. - * - * A reply is marked as one. Nothing in this app writes replies, so the mark is for - * an event that arrived some other way; drawing one as an announcement would put - * half a conversation on a group's page with no sign that it was half. - */ -@Composable -private fun GroupPostCard( - post: GroupPost, - publicKey: HexKey, - groupNostrProfile: GroupNostrProfile? -) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - ProfileAvatar( - size = MaterialTheme.spacing.space400, - publicKey = publicKey, - picture = groupNostrProfile?.picture(), - name = groupNostrProfile?.name() - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = groupNostrProfile?.name() ?: publicKey.take(16).inComparableGroups(), - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = stringResource( - Res.string.posted_on, - post.postedAt.toFormattedTimeAndDateString() - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (!post.isNewThread()) { - Text( - text = stringResource(Res.string.a_reply), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Text( - text = post.content, - style = MaterialTheme.typography.bodyMedium - ) - } - } -} - -/** The way into `AddGroupPostScreen`. The icon is decorative because the label is beside it. */ -@Composable -private fun AddPostButton(onClick: () -> Unit) { - TextButton(onClick = onClick) { - Icon(Icons.Default.Campaign, contentDescription = Decorative) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - - Text(stringResource(Res.string.add_post)) - } -} - -@ConformancePreviews -@Composable -private fun GroupPostsScreenPreview() { - val chatRoomId = "d".repeat(64) - - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - GroupPostsScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - initialGroupPostsUIState = GroupPostsUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - groupPosts = listOf( - GroupPost( - signedEvent = GroupSignedEvent( - id = "b".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = TextNoteEvent.KIND, - tags = emptyArray(), - content = "The first chapter is out. Read it, and tell us where it reads wrong.", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ) - ) - ), - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt deleted file mode 100644 index febfa89a..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreen.kt +++ /dev/null @@ -1,327 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.edit_relays -import mantra.composeapp.generated.resources.no_relays_in_this_list -import mantra.composeapp.generated.resources.not_set_yet -import mantra.composeapp.generated.resources.relay_set_blocked -import mantra.composeapp.generated.resources.relay_set_general -import mantra.composeapp.generated.resources.relay_set_messages -import mantra.composeapp.generated.resources.relay_set_search -import mantra.composeapp.generated.resources.relays -import org.jetbrains.compose.resources.stringResource -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.Decorative -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.buttons.BroadcastButton -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.GroupRelaysViewModel -import press.mantra.compose.ui.view.state.GroupRelaysUIState -import kotlin.time.Instant - -/** - * Where the group has said it lives on nostr, one line per relay list. - * - * Pushed from its row on the group's screen, which says how many of the four - * lists the group has agreed and nothing more. This is where they are read, - * with a broadcast under each one the group has signed and -- for a member - * holding a share of the key -- the way into the editor. - * - * There is no empty state to draw: every set gets a row whatever the group has - * said, and the row is where "never agreed" and "agreed empty" are told apart. - * See [GroupRelayListsCard]. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GroupRelaysScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialGroupRelaysUIState: GroupRelaysUIState = GroupRelaysUIState.Loading, - chatRepository: ChatRepository, - onNavigateToRoute: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val groupRelaysViewModel: GroupRelaysViewModel = viewModel( - factory = GroupRelaysViewModel.factory( - chatRoomId = chatRoomId, - initialGroupRelaysUIState = initialGroupRelaysUIState, - chatRepository = chatRepository, - ) - ) - - LaunchedEffect(true) { - if (initialGroupRelaysUIState == GroupRelaysUIState.Loading) { - groupRelaysViewModel.initiate() - } - } - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.relays), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { NavigateBackButton(onNavigateBack) } - ) - } - ) { padding -> - ScreenStateTransition(groupRelaysViewModel.groupRelaysUIState) { uiState -> - when (val state = uiState) { - is GroupRelaysUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, - // and reading it again with the same one fails the same way. - ErrorState( - modifier = Modifier.padding(padding), - message = state.message, - onRetry = null - ) - } - - GroupRelaysUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(padding) - .padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - LoadingDataIndicator(fillScreen = false) - } - } - - is GroupRelaysUIState.Loaded -> { - LazyColumn( - modifier = Modifier.padding(padding).readableContent().fillMaxSize() - .padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - item { - GroupRelayListsCard( - relayLists = state.groupRelayLists, - onBroadcast = { signedEvent -> - onNavigateToRoute.invoke( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - signedEventId = signedEvent.id - ) - ) - } - ) - } - - // The lists are worth reading either way; proposing them - // is a signature by the group, and `proposeSigningBatch` - // throws for a device holding no share of the key. Hidden - // rather than disabled, the way every gated way in on the - // group's screen is. - if (state.canSign) { - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - EditGroupRelaysRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon(Icons.Default.Dns, contentDescription = Decorative) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - - Text(stringResource(Res.string.edit_relays)) - } - } - } - } - } - } - } - } -} - -/** - * Where the group has said it lives on nostr, one line per relay list. - * - * One card holding four rows rather than four cards: they are read together -- the - * question is whether the group is reachable, not what any single list says -- and - * four cards of one line each is four dividers to scroll past for the same four - * facts. - * - * **A list the group has never agreed reads "not set yet", and an agreed empty one - * reads "no relays".** Those are different things: one is a group that has not got - * round to it, and the other is a group that decided. Collapsing them would hide a - * deliberate withdrawal behind an oversight. - */ -@Composable -private fun GroupRelayListsCard( - relayLists: List, - onBroadcast: (GroupSignedEvent) -> Unit -) { - Card(modifier = Modifier.fillMaxWidth()) { - relayLists.forEach { list -> - ListItem( - leadingContent = { - Icon(Icons.Default.Dns, contentDescription = Decorative) - }, - headlineContent = { - Text(text = stringResource(list.set.summaryLabel())) - }, - supportingContent = { - Text( - text = when { - !list.isAgreed -> stringResource(Res.string.not_set_yet) - list.relays.isEmpty() -> - stringResource(Res.string.no_relays_in_this_list) - // Hosts rather than URLs: every one of them starts - // `wss://`, so the scheme is the part that never - // distinguishes two entries. - else -> list.relays.joinToString { it.displayUrl() } - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - - // Under the row it sends, and only where there is something signed - // to send: a list the group has never agreed is not an event. Inside - // the card because the four rows share one, so "under the event" has - // to mean under the row -- and at the end, where M3 puts a card's - // actions. An agreed empty list gets one too: a withdrawal is a - // statement, and relays still holding the old list need to hear it. - list.signedEvent?.let { signedEvent -> - Box( - modifier = Modifier.fillMaxWidth() - .padding(horizontal = MaterialTheme.spacing.compactPadding), - contentAlignment = Alignment.CenterEnd - ) { - BroadcastButton(onClick = { onBroadcast(signedEvent) }) - } - } - } - } -} - -/** The name of a relay list, for the row that summarises it. */ -private fun GroupRelaySet.summaryLabel() = when (this) { - GroupRelaySet.General -> Res.string.relay_set_general - GroupRelaySet.Messages -> Res.string.relay_set_messages - GroupRelaySet.Search -> Res.string.relay_set_search - GroupRelaySet.Blocked -> Res.string.relay_set_blocked -} - -@ConformancePreviews -@Composable -private fun GroupRelaysScreenPreview() { - val chatRoomId = "d".repeat(64) - - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - GroupRelaysScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - initialGroupRelaysUIState = GroupRelaysUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - // One agreed list and three the group has not got round to, - // so the two things a row can say are both on the screen. - groupRelayLists = GroupRelaySet.entries.map { set -> - if (set == GroupRelaySet.General) { - GroupRelayList( - set = set, - signedEvent = GroupSignedEvent( - id = "e".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = set.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - relays = listOf( - GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example")) - ) - ) - } else { - GroupRelayList(set = set, signedEvent = null, relays = emptyList()) - } - }, - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt deleted file mode 100644 index 6b4f4a86..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt +++ /dev/null @@ -1,469 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.lifecycle.viewmodel.compose.viewModel -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupEventProposal -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.text.ProposedEvent -import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ErrorState -import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator -import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState -import press.mantra.compose.ui.composable.widgets.NavigateBackButton -import press.mantra.compose.ui.composable.widgets.ScreenStateTransition -import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar -import press.mantra.compose.ui.composable.widgets.rememberNotifier -import press.mantra.compose.ui.theme.ConformancePreviews -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.theme.readableContent -import press.mantra.compose.ui.theme.spacing -import press.mantra.compose.ui.view.model.ProposeGroupEventViewModel -import press.mantra.compose.ui.view.state.ProposeGroupEventUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import mantra.composeapp.generated.resources.Res -import mantra.composeapp.generated.resources.a_post_needs_something_to_say -import mantra.composeapp.generated.resources.an_event_is_a_json_object -import mantra.composeapp.generated.resources.an_event_needs_a_kind -import mantra.composeapp.generated.resources.content_must_be_text -import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_event -import mantra.composeapp.generated.resources.eg_kind_1_tags_content -import mantra.composeapp.generated.resources.kind_n -import mantra.composeapp.generated.resources.kind_n_has_nowhere_to_go_on_this_screen -import mantra.composeapp.generated.resources.paste_an_unsigned_event_for_the_group_to_sign -import mantra.composeapp.generated.resources.propose_event -import mantra.composeapp.generated.resources.tags_must_be_a_list_of_lists_of_strings -import mantra.composeapp.generated.resources.that_is_not_json -import mantra.composeapp.generated.resources.the_content_is_not_a_profile -import mantra.composeapp.generated.resources.the_group_signs_an_event_so_it_takes_a_quorum -import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_event -import mantra.composeapp.generated.resources.this_schema_cannot_be_signed_as_it_is -import mantra.composeapp.generated.resources.unsigned_event -import mantra.composeapp.generated.resources.what_the_group_would_sign -import org.jetbrains.compose.resources.stringResource - -/** - * An unsigned event, pasted, for the group to sign. - * - * The typed editors each build one kind of statement; this takes any of the - * kinds the group's screen can show, as JSON, the way another tool prints one. - * Above the paste is what the group would be signing, read back the way the - * signing screen will describe it, so a member sees "Curated schema · bitcoin.mov" - * rather than a wall of brackets before deciding -- and sees why the group will - * not be asked when the paste is something it could never show. - * - * The byline is the group's, as on the post composer and for the same reason: - * whatever is pasted goes out in the group's name, and a paste that names some - * other `pubkey` goes out in the group's name anyway. The explanatory line says - * so, because a paste with a `pubkey` in it is the one case where a member could - * reasonably expect otherwise. - */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) -@Composable -fun ProposeGroupEventScreen( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialProposeGroupEventUIState: ProposeGroupEventUIState = ProposeGroupEventUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository, - onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, - onNavigateBack: () -> Unit, -) { - val proposeGroupEventViewModel: ProposeGroupEventViewModel = viewModel( - factory = ProposeGroupEventViewModel.factory( - chatRoomId = chatRoomId, - relayHint = relayHint, - initialProposeGroupEventUIState = initialProposeGroupEventUIState, - activeUserPublicKey = activeUserPublicKey, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - // Read out here rather than in the click handler: both are composable and an - // onClick lambda is not. The scope is the caller's so a message survives this - // screen being replaced by the signing session. - val notify = rememberNotifier(rememberCoroutineScope()) - val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_event) - - ScreenStateTransition(proposeGroupEventViewModel.proposeGroupEventUIState) { uiState -> - when (val proposeGroupEventUIState = uiState) { - is ProposeGroupEventUIState.Error -> { - // Nothing to retry: the room came from a navigation argument, and - // reading it again with the same one fails the same way. - ErrorState(message = proposeGroupEventUIState.message, onRetry = null) - } - - is ProposeGroupEventUIState.Loaded -> { - val eventFieldState = rememberTextFieldState() - val canSign = proposeGroupEventUIState.canSign - - // Read on every change. A paste is a few kilobytes at most and the - // reading is one JSON parse, so this is cheaper than the debounce - // that would hide the answer for a beat after the paste. - val json = eventFieldState.text.toString() - val reading = remember(json) { - json.takeIf { it.isNotBlank() }?.let(GroupEventProposal::read) - } - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - // imePadding: this screen is a text field the size of the screen, - // and without it the software keyboard covers what is being pasted. - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { Text(text = stringResource(Res.string.propose_event)) }, - navigationIcon = { NavigateBackButton(onNavigateBack) }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canSign) return@ExtendedFloatingActionButton - - proposeGroupEventViewModel.proposeEvent( - localChatRoom = proposeGroupEventUIState.localChatRoom, - json = eventFieldState.text.toString(), - onSuccess = { sessionId -> - // Onto the session rather than back - // to the group. Nothing has been - // signed yet -- the event lands when - // a quorum signs -- so landing on a - // screen that does not show it would - // read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - // The reason is already under the paste; - // pressing the button anyway is answered - // rather than ignored, and the paste - // stays. Same on a failure to propose. - onRefused = {}, - onFailure = { notify(couldNotPropose) } - ) - } - ) { - Icon(Icons.Default.Draw, contentDescription = "Propose event") - Text(stringResource(Res.string.propose_event)) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Whose name this goes out in, said before anything is - // pasted. It is the group's, whatever the paste says. - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - ProfileAvatar( - size = MaterialTheme.spacing.space500, - publicKey = chatRoomId, - picture = proposeGroupEventUIState.groupNostrProfile?.picture(), - name = proposeGroupEventUIState.groupNostrProfile?.name() - ) - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) - - Text( - text = proposeGroupEventUIState.groupNostrProfile?.name() - ?: proposeGroupEventUIState.localChatRoom.chatRoom.subject - ?: chatRoomId.take(16), - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Text( - text = stringResource( - Res.string.paste_an_unsigned_event_for_the_group_to_sign - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource( - Res.string.the_group_signs_an_event_so_it_takes_a_quorum - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - if (!canSign) { - Text( - text = stringResource( - Res.string.this_group_has_no_shared_key_to_sign_an_event - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - - when (reading) { - null -> Unit - is GroupEventProposal.Ready -> ProposalPreview(reading) - is GroupEventProposal.Refused -> RefusalNotice(reading) - } - - OutlinedTextField( - modifier = Modifier.fillMaxWidth().weight(1f) - .background(BottomAppBarDefaults.containerColor), - state = eventFieldState, - // The paste grows into the screen rather than scrolling - // inside three lines, and is set in a monospace face: - // it is JSON, and a bracket that does not line up is - // the kind of thing a member is looking for in it. - lineLimits = TextFieldLineLimits.MultiLine(), - textStyle = TextStyle(fontFamily = FontFamily.Monospace), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - label = { Text(stringResource(Res.string.unsigned_event)) }, - placeholder = { - Text( - text = stringResource(Res.string.eg_kind_1_tags_content), - fontFamily = FontFamily.Monospace - ) - }, - isError = reading is GroupEventProposal.Refused - ) - } - } - } - - ProposeGroupEventUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) - - Text( - text = stringResource(Res.string.propose_event), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator(fillScreen = false) - } - } - } - } - - LaunchedEffect(true) { - if (initialProposeGroupEventUIState == ProposeGroupEventUIState.Loading) { - proposeGroupEventViewModel.initiateProposeGroupEvent() - } - } -} - -/** - * What the group would sign, said the way the signing screen will say it. - * - * `ProposedEvent.summarize` rather than a description of this screen's own, so - * that what a member reads here before proposing is word for word what every - * other member reads before signing. Two descriptions of one event would drift. - */ -@Composable -private fun ProposalPreview(ready: GroupEventProposal.Ready) { - val summary = ProposedEvent.summarize(ready.preview()) - - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - text = stringResource(Res.string.what_the_group_would_sign), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = stringResource(Res.string.kind_n, ready.kind.toString()) + " · " + summary.label, - style = MaterialTheme.typography.titleSmall - ) - - // Capped, because a kind:1's detail is the whole post and the paste - // under this card is the thing the screen is for. - Text( - text = summary.detail, - style = MaterialTheme.typography.bodyMedium, - maxLines = 6, - overflow = TextOverflow.Ellipsis - ) - } - } -} - -/** - * Why the group will not be asked, under the paste it is about. - * - * Named rather than silent: a button that does nothing for a paste it refuses is - * indistinguishable from a missed tap. A schema's reasons are the same sentences - * the schema editor uses, since they are the same checks. - */ -@Composable -private fun RefusalNotice(refused: GroupEventProposal.Refused) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) - ) { - Text( - text = when (refused) { - GroupEventProposal.Refused.NotJson -> stringResource(Res.string.that_is_not_json) - GroupEventProposal.Refused.NotAnObject -> stringResource(Res.string.an_event_is_a_json_object) - GroupEventProposal.Refused.NoKind -> stringResource(Res.string.an_event_needs_a_kind) - is GroupEventProposal.Refused.KindNotAccepted -> stringResource( - Res.string.kind_n_has_nowhere_to_go_on_this_screen, - refused.kind.toString(), - GroupEventProposal.ACCEPTED_KINDS.sorted().joinToString() - ) - GroupEventProposal.Refused.TagsMalformed -> - stringResource(Res.string.tags_must_be_a_list_of_lists_of_strings) - GroupEventProposal.Refused.ContentNotText -> stringResource(Res.string.content_must_be_text) - GroupEventProposal.Refused.ProfileUnreadable -> - stringResource(Res.string.the_content_is_not_a_profile) - GroupEventProposal.Refused.PostBlank -> stringResource(Res.string.a_post_needs_something_to_say) - is GroupEventProposal.Refused.SchemaUnusable -> - stringResource(Res.string.this_schema_cannot_be_signed_as_it_is) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - - if (refused is GroupEventProposal.Refused.SchemaUnusable) { - refused.problems.forEach { problem -> - Text( - text = problem.message(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } -} - -@ConformancePreviews -@Composable -private fun ProposeGroupEventScreenPreview() { - MantraTheme { - Surface(modifier = Modifier.fillMaxSize()) { - ProposeGroupEventScreen( - activeUserPublicKey = "", - chatRoomId = "publicKey", - relayHint = null, - initialProposeGroupEventUIState = ProposeGroupEventUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = "publicKey", - userPublicKey = "", - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ), - ), - // A group that can sign, so the screen renders in the state a - // member actually meets it in rather than greyed out. - canSign = true - ), - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {}, - onNavigateBack = {}, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index c70940d1..5b6a684f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -40,19 +40,7 @@ import press.mantra.compose.ui.composable.DkgJoinApprovalScreen import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen -import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen -import press.mantra.compose.ui.composable.AddGroupPostScreen -import press.mantra.compose.ui.composable.AcceptCuratedSuggestionScreen -import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen -import press.mantra.compose.ui.composable.CuratedSuggestionListScreen -import press.mantra.compose.ui.composable.ProposeGroupEventScreen -import press.mantra.compose.ui.composable.EditGroupRelaysScreen -import press.mantra.compose.ui.composable.GroupNostrProfileScreen -import press.mantra.compose.ui.composable.GroupRelaysScreen -import press.mantra.compose.ui.composable.GroupPostsScreen -import press.mantra.compose.ui.composable.GroupCuratedSchemasScreen -import press.mantra.compose.ui.composable.GroupCuratedEntriesScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen import press.mantra.compose.ui.composable.KeyPackageManagementScreen @@ -139,19 +127,7 @@ import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute -import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute -import press.mantra.compose.ui.composable.navigation.routes.AcceptCuratedSuggestionRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute -import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute @@ -1116,223 +1092,6 @@ fun MantraNavHost( } ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - GroupNostrProfileScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate(route = actionRoute) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - GroupRelaysScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate(route = actionRoute) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - GroupPostsScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate(route = actionRoute) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - GroupCuratedSchemasScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate(route = actionRoute) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - GroupCuratedEntriesScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate(route = actionRoute) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - EditGroupNostrProfileScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace the form so back returns to the group rather than - // to an edit whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - EditGroupRelaysScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace the editor so back returns to the group rather - // than to lists whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - AddGroupPostScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace the composer so back returns to the group rather - // than to a draft whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - EditGroupCuratedSchemaScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - identifier = route.identifier, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace the editor so back returns to the group rather - // than to a schema whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - CuratedSuggestionListScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - identifier = route.identifier, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToRoute = { acceptRoute -> - navController.navigate(route = acceptRoute) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - AcceptCuratedSuggestionScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - identifier = route.identifier, - suggestionId = route.suggestionId, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace the form so back returns to the queue rather - // than to an entry whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - } - ) - } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -1352,29 +1111,6 @@ fun MantraNavHost( } ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ProposeGroupEventScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace the paste so back returns to the group rather - // than to an event whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt deleted file mode 100644 index 2ade6774..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt +++ /dev/null @@ -1,22 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * The form for taking one suggestion into a list the group curates: the entry - * as it was suggested, open for the group's edits, and a button that asks the - * group to sign it as the list's own. - * - * [identifier] is the schema's `d`, for the reason `CuratedSuggestionListRoute` - * gives; [suggestionId] is the suggestion's event id rather than its - * coordinate, because the canonical entry pins the exact version the group - * read -- the NIP's `e` tag -- and the id is what names a version. - */ -@Serializable -data class AcceptCuratedSuggestionRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String?, - val identifier: String, - val suggestionId: String -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt deleted file mode 100644 index 7400f78f..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt +++ /dev/null @@ -1,10 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -@Serializable -data class AddGroupPostRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt deleted file mode 100644 index 9659aecb..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt +++ /dev/null @@ -1,19 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * The queue of one list the group curates: everything anyone has suggested to - * the schema at [identifier], read off the relays the schema names. - * - * Named by the schema's `d` for the reason `EditGroupCuratedSchemaRoute` is: - * the coordinate is what every suggestion replies to, and the group re-signing - * the schema changes the event without changing the queue. - */ -@Serializable -data class CuratedSuggestionListRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String?, - val identifier: String -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt deleted file mode 100644 index a56bf15b..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt +++ /dev/null @@ -1,20 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * The editor for one of a group's curated schemas. - * - * [identifier] is the schema's `d`, and null opens the editor on a new one. It - * is the identifier rather than the event id because kind 31889 is addressable: - * an edit replaces the event under the same `d`, and the editor's job is to - * propose that replacement, so the coordinate is what names the thing being - * edited and the event is only its current version. - */ -@Serializable -data class EditGroupCuratedSchemaRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String?, - val identifier: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt deleted file mode 100644 index 426ce90c..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt +++ /dev/null @@ -1,10 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -@Serializable -data class EditGroupNostrProfileRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt deleted file mode 100644 index 5c0fe7f0..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt +++ /dev/null @@ -1,10 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -@Serializable -data class EditGroupRelaysRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt deleted file mode 100644 index e70359a3..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedEntriesRoute.kt +++ /dev/null @@ -1,16 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * The entries the group has accepted into the lists it curates, newest first, - * with a broadcast under each. Nothing is added from here: an entry is accepted - * from the queue it was suggested to, which is reached from its schema's card - * on the [GroupCuratedSchemasRoute] screen. - */ -@Serializable -data class GroupCuratedEntriesRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt deleted file mode 100644 index ab698bf9..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupCuratedSchemasRoute.kt +++ /dev/null @@ -1,16 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * The lists the group curates, as the schemas it signed for them, one card per - * list with its queue and its broadcast under it. Each card is the way into - * that schema's editor for a member holding a share of the key, and the screen - * ends with the way to add one. - */ -@Serializable -data class GroupCuratedSchemasRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt deleted file mode 100644 index c88e74a1..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupNostrProfileRoute.kt +++ /dev/null @@ -1,19 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * What the group says about itself on nostr, as the group signed it. - * - * The group's screen names the profile in a row; this is where it is read, with - * the broadcast under it and, for a member holding a share of the key, the way - * into the editor. One of the five screens the group's signed work is split - * across -- see [GroupRelaysRoute], [GroupPostsRoute], [GroupCuratedSchemasRoute] - * and [GroupCuratedEntriesRoute] -- each pushed from its row on the group's screen. - */ -@Serializable -data class GroupNostrProfileRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt deleted file mode 100644 index da9a5623..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupPostsRoute.kt +++ /dev/null @@ -1,15 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * What the group has said publicly, as itself, newest first, with a broadcast - * under each post and the way into the composer for a member holding a share - * of the key. - */ -@Serializable -data class GroupPostsRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt deleted file mode 100644 index dfa88279..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/GroupRelaysRoute.kt +++ /dev/null @@ -1,15 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** - * Where the group has said it lives on nostr: its four relay lists, as the - * group signed them, with a broadcast under each one it has agreed and the way - * into the editor for a member holding a share of the key. - */ -@Serializable -data class GroupRelaysRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt deleted file mode 100644 index 71f12f23..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt +++ /dev/null @@ -1,11 +0,0 @@ -package press.mantra.compose.ui.composable.navigation.routes - -import kotlinx.serialization.Serializable - -/** The untyped way into a group's signed statements: an unsigned event, pasted. */ -@Serializable -data class ProposeGroupEventRoute( - val activeUserPublicKey: String, - val chatRoomId: String, // TODO: have this as a publicKey - val relayHint: String? -): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt index 3912ff8b..33464200 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/BroadcastButton.kt @@ -17,9 +17,10 @@ import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.theme.spacing /** - * The way into `BroadcastGroupSignedEventScreen`, drawn the same under every - * event the group signed -- its profile, each relay list, each post, each schema - * and each entry -- so that it reads as one action on whichever screen it sits. + * The way into `BroadcastGroupSignedEventScreen`, to be drawn the same under + * every event the group signed so that it reads as one action on whichever + * screen it sits. Nothing draws it at present -- the screens that did have + * gone -- and it is kept, with the screen it opens, for whatever next does. * * Behind no gate anywhere it appears: sending what the group has already signed * takes no share of the key, since the signature on it is the group's whoever diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index 730d8046..75db2d21 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -340,25 +340,6 @@ fun ChatTranscript( return@items } - // The group's own profile, signed. Same treatment - // and for the same reason: nobody said it. Answered - // and settled because it reports rather than asks -- - // a quorum has already signed by the time this line - // exists. - // - // It leads nowhere. What a reader wants from one is - // the profile, and the nostr profile section of the - // group's detail screen is where that lives. - if (localChatMessage.chatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES) { - RitualNotice( - localChatMessage = localChatMessage, - isAnswered = true, - isSettled = true, - onClick = {} - ) - return@items - } - // Signing lines are the same kind of thing and get // the same treatment -- nobody said them either -- // but they lead somewhere else, because what a diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt index d5c42b01..d0b1a198 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt @@ -43,35 +43,6 @@ fun ProfileAvatar( shape: Shape = CircleShape, publicKey: String, profile: press.mantra.compose.database.model.Profile? -) { - ProfileAvatar( - size = size, - shape = shape, - publicKey = publicKey, - picture = profile?.picture, - name = profile?.displayName - ) -} - -/** - * The same avatar for a subject that has no `Profile` row. - * - * A group's is the case in hand: its kind:0 is signed by the group and kept as a - * `GroupSignedEvent`, which is not a `NostrEvent` and so cannot have a `Profile` - * hanging off it -- see `GroupNostrProfile`. The picture and the name are all this - * ever wanted from a profile, so they are what it takes. - * - * [name] only reaches the content description. The tint behind a missing picture - * comes from [publicKey], which is what keeps the same subject the same colour - * everywhere it appears. - */ -@Composable -fun ProfileAvatar( - size: Dp = 55.dp, - shape: Shape = CircleShape, - publicKey: String, - picture: String?, - name: String? = null ) { val modifier = Modifier.size(size).clip(shape = shape) @@ -79,7 +50,7 @@ fun ProfileAvatar( publicKey ) - if (picture == null) { + if (profile?.picture == null) { Icon( modifier = modifier, imageVector = Icons.Default.AccountCircle, @@ -106,9 +77,9 @@ fun ProfileAvatar( ) AsyncImage( - model = picture, + model = profile.picture, modifier = modifier, - contentDescription = "Profile picture for ${name ?: publicKey}", + contentDescription = "Profile picture for ${profile.displayName ?: profile.publicKey}", placeholder = placeHolderGraphic, fallback = fallbackGraphic, error = errorGraphic, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt deleted file mode 100644 index d172ec2f..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt +++ /dev/null @@ -1,324 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.curated.CuratedEntryProblem -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedSuggestionRef -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState - -/** - * One suggestion, taken into the list: the entry as somebody suggested it, - * edited by a member, and proposed as the group's own. - * - * Nothing here is saved. The working copy is one input per value the entry - * has, held on this view model rather than in the composition so that the - * window turning does not throw an edit away, and the button folds it into a - * kind 31890 for the room's quorum to put its key to. The list changes on - * every member's device at once, when a quorum has signed, or not at all. - * - * ### What is edited, and what is kept - * - * Curation is editorial -- the NIP says the curator may fix a year or swap a - * poster on the way through -- so every field the schema's form asks for is - * open. Two things are not. The `d` is kept whatever field writes it, because - * the canonical entry SHOULD land on the suggestion's coordinate so that - * accepting it twice revises one entry rather than making two. And a field the - * schema marks `derived` is kept as the suggester's client filled it in: this - * app derives nothing, and an entry a form never asked for is not one a form - * should let a member invent. - * - * ### Refused before proposed - * - * The button runs the queue's own verifier over the event it is about to - * propose, and refuses with the fields named. A signature is the most expensive - * thing this app does, and a canonical entry the queue would not count -- a - * year outside the schema's range, an option that is not one -- would be a - * quorum spent on an entry no client shows. - */ -class AcceptCuratedSuggestionViewModel( - val chatRoomId: String, - val activeUserPublicKey: HexKey, - val relayHint: String?, - /** The `d` of the schema whose list the entry goes into. */ - val identifier: String, - /** The event id of the suggestion being taken up. */ - val suggestionId: String, - initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState, - val nostrRepository: NostrRepository, - val chatRepository: ChatRepository, - val frostSigningRepository: FrostSigningRepository, -): ViewModel() { - - var acceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState by mutableStateOf( - initialAcceptCuratedSuggestionUIState - ) - private set - - private val logger = Logger.withTag(TAG) - - val isActionPending: MutableState = mutableStateOf(false) - - /** - * The working copy: for each field the form asks for, one input per value. - * - * A field that repeats has as many inputs as the suggestion had values, and - * can be given more; every other field has exactly one. Keyed by the - * schema's field name, which is what the entry is keyed by too. - */ - val inputs: SnapshotStateMap> = mutableStateMapOf() - - /** - * Why the last proposal did not go out, or nothing. Set by the button and - * left showing until the next press, since the button is the thing that - * will clear it. - */ - var problems: List by mutableStateOf(emptyList()) - private set - - private var isSeeded = false - - fun initiate() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - if (localChatRoom == null) { - acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find the group") - return@launch - } - - val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier } - if (schema == null) { - acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that schema") - return@launch - } - - // The same reading the queue screen makes, so what is accepted here - // is what was shown there: newest per coordinate, checked against - // the schema, and marked when the group already has an entry for it. - // An id the reading no longer holds is a version something newer has - // replaced since the row was tapped, and the newer one is what the - // queue now shows. - val events = nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()).first() - val suggestion = CuratedSuggestion.queueOf(schema, events).firstOrNull { it.id == suggestionId } - if (suggestion == null) { - acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that suggestion") - return@launch - } - - acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded( - localChatRoom = localChatRoom, - schema = schema, - suggestion = suggestion, - canSign = localChatRoom.chatRoom.mlsGroupState != null && - frostSigningRepository.canSign(chatRoomId), - ) - } - } - - /** - * Fills the working copy from the suggestion, once. - * - * One input per value the suggestion carries for each field the form asks - * for, and one blank input for a field it carries nothing for, so there is - * somewhere to type. Called from the screen rather than from [initiate], - * because the loader is not the only way a loaded state arrives: a preview - * and a layout test both hand one straight in. Runs once, so the effect - * that calls it re-firing does not undo an edit. - */ - fun seedWorkingCopy(state: AcceptCuratedSuggestionUIState.Loaded) { - if (isSeeded) return - isSeeded = true - - val entry = state.suggestion.entry - - editableFields(state).forEach { field -> - val values = entry.values[field.name].orEmpty().ifEmpty { listOf("") } - inputs[field.name] = mutableStateListOf(*values.map { TextFieldState(it) }.toTypedArray()) - } - } - - /** Adds a blank input under [fieldName], for a field that repeats. */ - fun addValue(fieldName: String) { - inputs[fieldName]?.add(TextFieldState()) - } - - /** - * Drops the input at [index] under [fieldName]. The last input stays, blank - * rather than gone, so the field is still there to be typed into. - */ - fun removeValue(fieldName: String, index: Int) { - val values = inputs[fieldName] ?: return - if (index !in values.indices) return - - if (values.size == 1) { - values[0] = TextFieldState() - } else { - values.removeAt(index) - } - } - - /** - * The problems about [fieldName], for the field's own error state. A - * `require-any` rule is filed under its first field, so it shows there. - */ - fun problemsFor(fieldName: String): List = problems.filter { it.field == fieldName } - - /** - * The problems that are about no field on the form: the entry as a whole, - * or a field the form keeps rather than asks for. Shown at the top, since - * there is no input to show them under. - */ - fun problemsAboutTheEntry(state: AcceptCuratedSuggestionUIState.Loaded): List { - val editable = editableFields(state).map { it.name }.toSet() - return problems.filterNot { it.field in editable } - } - - /** - * The entry as the form has it: what was typed for every field the form - * asks for, and what was suggested for every field it keeps. - * - * Keyed by field name and holding every value, which is the shape - * `CuratedEntryEvent.canonicalTemplate` lays out as tags. Blank inputs come - * through and are dropped there; a blank is not a value. - */ - fun draft(state: AcceptCuratedSuggestionUIState.Loaded): Map> { - val suggested = state.suggestion.entry.values - - return state.schema.schema.fields.associate { field -> - field.name to (inputs[field.name]?.map { it.text.toString() } ?: suggested[field.name].orEmpty()) - } - } - - /** - * Opens a session over the entry, or says why not. - * - * Two refusals before anything is proposed and each is the caller's to - * say: an entry the queue would refuse, which is [onInvalid] with the - * reasons left in [problems]; and a proposal that could not be opened, - * which is [onFailure]. Both stay on the form with the edits still in it. - */ - fun proposeEntry( - state: AcceptCuratedSuggestionUIState.Loaded, - onSuccess: (sessionId: String) -> Unit, - onInvalid: () -> Unit, - onFailure: () -> Unit - ) { - // Guard against double submits. Two sessions over two versions of one - // entry would leave the list decided by whichever quorum finished last. - if (isActionPending.value) return - - val template = state.schema.canonicalTemplate( - values = draft(state), - source = CuratedSuggestionRef.of(state.suggestion.entry), - ) - - problems = state.schema.verifyCanonical(template) - if (problems.isNotEmpty()) { - onInvalid.invoke() - return - } - - isActionPending.value = true - - viewModelScope.launch(Dispatchers.IO) { - val session = runCatching { - frostSigningRepository.proposeSigning( - localChatRoom = state.localChatRoom, - userPublicKey = activeUserPublicKey, - kind = template.kind, - tags = template.tags, - content = template.content, - ) - }.onFailure { error -> - logger.e("Failed to propose a canonical entry for ${state.schema.coordinate()}", error) - }.getOrNull() - - viewModelScope.launch(Dispatchers.Main) { - if (session != null) { - onSuccess.invoke(session.id) - } else { - onFailure.invoke() - } - } - - isActionPending.value = false - } - } - - companion object { - private const val TAG = "AcceptCuratedSuggestionViewModel" - - /** The tag of the field the form keeps whatever else it says: the entry's coordinate. */ - private const val IDENTIFIER_TAG = "d" - - /** - * The fields the form opens for editing: the ones the schema's form asks - * for, less the one writing to `d`. See the class note for why those two - * are kept as they were suggested. - */ - fun editableFields(state: AcceptCuratedSuggestionUIState.Loaded): List = - state.schema.schema.formFields().filterNot { it.tag() == IDENTIFIER_TAG } - - /** - * The fields the form shows but does not open: what the suggester's client - * filled in, and what the entry is addressed by. In the schema's order, - * and only the ones the suggestion has a value for, since a kept field - * with nothing in it has nothing to show. - */ - fun keptFields(state: AcceptCuratedSuggestionUIState.Loaded): List { - val editable = editableFields(state).map { it.name }.toSet() - val values = state.suggestion.entry.values - - return state.schema.schema.fields.filter { it.name !in editable && values[it.name].orEmpty().isNotEmpty() } - } - - fun factory( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - identifier: String, - suggestionId: String, - initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState = - AcceptCuratedSuggestionUIState.Loading, - nostrRepository: NostrRepository, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - AcceptCuratedSuggestionViewModel( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = identifier, - suggestionId = suggestionId, - initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt deleted file mode 100644 index a4fd0d0e..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt +++ /dev/null @@ -1,154 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import co.touchlab.kermit.Logger -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.AddGroupPostUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch - -/** - * Asks the group to say something publicly, in its own name. - * - * The post is not written here and does not exist yet. What goes out is a proposal - * to sign one, and the post appears -- on every member's device at once, authored by - * the room's own key rather than by whoever typed it -- when enough members have - * signed. That author is the room's id: signing runs at the path the room was - * derived at, so a post says which group made it simply by being signed. - * - * That is the difference from a message in the room. A message is a member speaking - * where the group can hear; this is the group speaking where anyone can, and it - * takes a quorum to say. - */ -class AddGroupPostViewModel( - val chatRoomId: String, - val activeUserPublicKey: HexKey, - val relayHint: String?, - initialAddGroupPostUIState: AddGroupPostUIState, - val chatRepository: ChatRepository, - val frostSigningRepository: FrostSigningRepository, -): ViewModel() { - - var addGroupPostUIState: AddGroupPostUIState by mutableStateOf(initialAddGroupPostUIState) - private set - - private val logger = Logger.withTag(TAG) - - val isActionPending: MutableState = mutableStateOf(false) - - fun initiateAddGroupPost() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - addGroupPostUIState = if (localChatRoom == null) { - AddGroupPostUIState.Error("Couldn't find the chat room") - } else { - AddGroupPostUIState.Loaded( - localChatRoom = localChatRoom, - groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), - // A NIP-17 room has no key of its own to sign as, so it has no - // identity to post under -- the same condition the group detail - // screen gates the entry point on. - canSign = localChatRoom.chatRoom.mlsGroupState != null && - frostSigningRepository.canSign(chatRoomId), - ) - } - } - } - - /** - * Opens a session over one kind:1 authored by the group. - * - * Refuses a blank draft rather than proposing it: a quorum signing an empty note - * would put a post on the record that renders as nothing and cannot be told from - * a broken one -- and kind:1 is not replaceable, so there is no un-saying it. - * `GroupPost.newestFirstAmong` drops blanks on the way back out too, which is - * the second line of the same defence. - * - * The draft is not cleared on success. This screen is replaced by the signing - * session, and on the failure path the words are the thing worth keeping. - */ - fun proposePost( - localChatRoom: LocalChatRoom, - noteField: TextFieldState, - onSuccess: (sessionId: String) -> Unit, - onEmpty: () -> Unit, - onFailure: () -> Unit - ) { - // Guard against double submits. Posts accumulate rather than replace, so a - // second session over the same words is a second post on the record forever. - if (isActionPending.value) return - - val note = noteField.text.toString().trim() - if (note.isBlank()) { - onEmpty.invoke() - return - } - - isActionPending.value = true - - val template = GroupPost.template(note) - - viewModelScope.launch(Dispatchers.IO) { - val session = runCatching { - frostSigningRepository.proposeSigning( - localChatRoom = localChatRoom, - userPublicKey = activeUserPublicKey, - kind = template.kind, - tags = template.tags, - content = template.content, - ) - }.onFailure { error -> - logger.e("Failed to propose a post for $chatRoomId", error) - }.getOrNull() - - viewModelScope.launch(Dispatchers.Main) { - if (session != null) { - onSuccess.invoke(session.id) - } else { - onFailure.invoke() - } - } - - isActionPending.value = false - } - } - - companion object { - private const val TAG = "AddGroupPostViewModel" - - fun factory( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - AddGroupPostViewModel( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - initialAddGroupPostUIState = initialAddGroupPostUIState, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt index 1f811e9e..c4b66f41 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt @@ -15,7 +15,9 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -32,12 +34,7 @@ import press.mantra.compose.network.relays.EventPublishTransport import press.mantra.compose.network.relays.NostrPublishResult import press.mantra.compose.network.relays.RelayPool import press.mantra.compose.network.sockets.NostrIncomingMessage -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.Relays -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState import kotlin.time.Duration.Companion.milliseconds @@ -67,11 +64,19 @@ import kotlin.time.Duration.Companion.milliseconds * * ### Where the relays come from * - * [defaultRelaysFor]: the group's own General list where it has agreed one, the - * app's publish set where it has not, plus the indexers for a relay list and the - * list's own relays for a schema and for an entry into it, minus anything the - * group has blocked. The member can add and remove before sending, and the - * seeded list is a suggestion rather than a rule. + * [defaultRelaysFor]: the app's own publish set, the same relays everything the + * app publishes as itself goes to. A group has no relay list of its own to + * prefer, so the seed is the same for every event of every kind. The member can + * add and remove before sending, and the seeded list is a suggestion rather + * than a rule. + * + * ### Nothing opens this screen yet + * + * The rows that carried a Broadcast button under each signed event -- the + * group's profile, relay lists, posts and curated lists -- have gone, and no + * screen that stayed has one. `BroadcastGroupSignedEventRoute` is still + * registered and `BroadcastButton` still built, so wiring a button in is all it + * takes to reach this again. */ class BroadcastGroupSignedEventViewModel( val chatRoomId: String, @@ -177,32 +182,24 @@ class BroadcastGroupSignedEventViewModel( else -> BroadcastGroupSignedEventUIState.Loaded( localChatRoom = localChatRoom, signedEvent = signedEvent, - relayLists = chatRepository.groupRelayLists(chatRoomId), - schemas = chatRepository.groupCuratedSchemas(chatRoomId), ) } } } /** - * Fills the list from what the group has said, once. + * Fills the list with the defaults, once. * - * Called from the screen rather than from the loader, for the reason the - * relay editor's `seedWorkingCopy` gives: a preview and a layout test hand a - * loaded state straight in. Once and only once, so a member who emptied the - * list on purpose does not find it refilled by a state the view model re-emits. + * Called from the screen rather than from the loader, because a preview and + * a layout test hand a loaded state straight in. Once and only once, so a + * member who emptied the list on purpose does not find it refilled by a + * state the view model re-emits. */ - fun seedRelays( - relayLists: List, - kind: Kind, - tags: Array>, - content: String, - schemas: List = emptyList(), - ) { + fun seedRelays(kind: Kind) { if (isSeeded) return isSeeded = true - relays.addAll(defaultRelaysFor(relayLists, kind, tags, content, schemas)) + relays.addAll(defaultRelaysFor(kind)) } fun clearAddFailure() { @@ -215,7 +212,7 @@ class BroadcastGroupSignedEventViewModel( * True when the row appeared, which is the caller's cue to clear the field. */ fun addRelay(url: String): Boolean { - val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run { + val relayUrl = relayUrlOrNull(url) ?: run { addFailure = AddFailure.NotARelay return false } @@ -375,66 +372,32 @@ class BroadcastGroupSignedEventViewModel( /** * Where an event of [kind] should go, before the member has said otherwise. * - * The group's General list is the answer NIP-65 gives -- its write relays, - * where it has agreed any -- and the app's own publish set stands in for a - * group that has not, since a list of nothing would make the button a - * no-op on every group until somebody edits it. Two kinds add to that: - * - * - A relay list goes to the indexers as well. The list says where to find - * the group, so sending it only to the relays it names is circular -- a - * reader who already knew those relays would not need the list. - * - A curated schema goes to the relays it names for its own replies, so a - * client reading suggestions there finds what they are replying to. - * - * - An entry into a curated list goes to the same relays as its schema, - * which the NIP makes a MUST: that is where the list's readers look, - * and where the queue screen reads the entry back to mark the - * suggestion it came from. The entry names its schema by coordinate, - * so the relays are read off the group's own copy of it in [schemas]. - * - * And nothing goes to a relay the group has blocked, whatever the other - * lists say; that is what a blocked list is for. + * The app's own publish set, for every kind alike. A group says nothing + * about where it lives -- it signs no relay list of its own -- so there + * is no list of the group's to prefer and nothing of the group's to + * exclude, and the set the app publishes its own events to is what + * stops the button being a no-op on every group until somebody types a + * relay in. The kind is taken so a caller that has one to route by need + * not change its call when there is something to route on again. */ - fun defaultRelaysFor( - relayLists: List, - kind: Kind, - tags: Array> = emptyArray(), - content: String = "", - schemas: List = emptyList(), - ): List { - val general = relayLists.firstOrNull { it.set == GroupRelaySet.General } - ?.relays.orEmpty() - .filter { it.write } - .map { it.url } + @Suppress("UNUSED_PARAMETER") + fun defaultRelaysFor(kind: Kind): List = + Relays.eventPublishRelaySet.toList() - val where = general.ifEmpty { Relays.eventPublishRelaySet.toList() } + /** + * [url] as a relay an event could be sent to, or null. + * + * Structural only, and deliberately narrow: `wss://` because what is + * sent is read by strangers over the open network and `ws://` would ask + * them to fetch it in the clear, and no loopback because a relay only + * this device can reach is not somewhere a broadcast goes. Wisp's + * `RelayConfig.isValidUrl` refuses the same three things. + */ + fun relayUrlOrNull(url: String): NormalizedRelayUrl? { + val trimmed = url.trim() + if (!trimmed.startsWith("wss://", ignoreCase = true)) return null - val indexers = if (kind in GroupRelaySet.KINDS) { - Relays.DefaultIndexerRelayList.toList() - } else { - emptyList() - } - - val schemaRelays = when (kind) { - CuratedSchemaEvent.KIND -> - runCatching { CuratedSchemaEvent.read(tags, content).relays } - .getOrDefault(emptyList()) - .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } - - CuratedEntryEvent.CANONICAL_KIND -> - schemas.firstOrNull { it.isRepliedToBy(tags) } - ?.schema?.relays.orEmpty() - .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } - - else -> emptyList() - } - - val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked } - ?.relays.orEmpty() - .map { it.url } - .toSet() - - return (where + indexers + schemaRelays).distinct().filterNot { it in blocked } + return RelayUrlNormalizer.normalizeOrNull(trimmed)?.takeUnless { it.isLocalHost() } } fun factory( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index ec1f9afc..3bfb3db2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -74,12 +74,6 @@ class ChatRoomDetailViewModel( // Initiate new chat... and navigate to new chat... ChatRoomDetailUIState.Error("Missing chat room") } else { - // One read of the capability for the two things that need it. It - // walks the room's ceremonies looking for a share, so asking twice - // would do the same walk twice for one answer. - val canSign = localChatRoom.chatRoom.mlsGroupState != null && - chatRepository.canSign(chatRoomId) - ChatRoomDetailUIState.Loaded( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), @@ -88,13 +82,8 @@ class ChatRoomDetailViewModel( signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), parentChatRoomId = chatRepository.parentOf(chatRoomId), - groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), - groupRelayLists = chatRepository.groupRelayLists(chatRoomId), - groupPosts = chatRepository.groupPosts(chatRoomId), - groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId), - groupCuratedEntries = chatRepository.groupCuratedEntries(chatRoomId), - canEditNostrProfile = canSign, - canAddSubgroup = canSign, + canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId), ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt deleted file mode 100644 index cc981945..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt +++ /dev/null @@ -1,257 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import press.mantra.compose.database.model.SynchronizeNostrEventRequest -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState -import kotlin.time.Duration.Companion.seconds - -/** - * The queue of one list the group curates, read off the relays the schema - * names. - * - * A pull, the way every one-off read in this app is: a request row per relay - * for the sync pump to drain, and a watch on the local table for what comes - * back. Nothing here talks to a relay itself. What the screen shows is - * therefore what this device has *stored* for the coordinate, which is also - * what it shows with no network at all -- and what it showed last time, until - * the relays answer again. - * - * ### Where the queue is read from - * - * The schema's `relay` tags, first. They are signed into the schema for exactly - * this: a client that finds the list anywhere knows where its replies live and - * cannot be sent elsewhere by an unsigned config. A schema that names none -- - * the NIP allows it and says a client may then pick its own -- is read from - * the group's general relay list, which is where the group said it publishes, - * and failing that from this build's default. The screen says which. - * - * ### What "still looking" means - * - * The pump marks a request sent when it goes out and processed when an event - * comes back, and a relay holding nothing for the coordinate answers with an - * EOSE the pump does not record. So there is no signal for "asked and empty", - * and the screen would otherwise call every list empty for the second before - * its rows arrived. [ASK_TIMEOUT] stands in: the queue is being asked until - * something lands or the wait runs out, and after that an empty queue is - * empty. The reference client does the same with a six-second timer. - */ -class CuratedSuggestionListViewModel( - val chatRoomId: String, - val activeUserPublicKey: HexKey, - val relayHint: String?, - /** The `d` of the schema whose queue this is. */ - val identifier: String, - initialCuratedSuggestionListUIState: CuratedSuggestionListUIState, - val nostrRepository: NostrRepository, - val chatRepository: ChatRepository, -): ViewModel() { - - var curatedSuggestionListUIState: CuratedSuggestionListUIState by mutableStateOf( - initialCuratedSuggestionListUIState - ) - private set - - private val logger = Logger.withTag(TAG) - - /** - * Serialises the read-modify-write on the state. The watch, the timer and - * a re-ask each update one field of it from their own coroutine, and a copy - * taken by one and written after another's is a lost update. - */ - private val stateMutex = Mutex() - - private var askTimeout: Job? = null - - fun initiate() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - if (localChatRoom == null) { - curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find the group") - return@launch - } - - // Opened on a schema this device does not hold: nothing to ask for, - // since the coordinate is the group's key and the identifier and - // half of that came from a screen that no longer agrees. - val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier } - if (schema == null) { - curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find that schema") - return@launch - } - - val relays = relaysToAsk( - schema = schema, - general = chatRepository.groupRelayLists(chatRoomId).firstOrNull { it.set == GroupRelaySet.General }, - ) - - curatedSuggestionListUIState = CuratedSuggestionListUIState.Loaded( - localChatRoom = localChatRoom, - schema = schema, - relays = relays, - isAsking = true, - ) - - ask(schema, relays) - watch(schema) - } - } - - /** - * Asks the relays again, for a member who believes something is missing. - * - * The one thing the watch does not do on its own: it shows what arrives, - * and this is how more is made to arrive. Idempotent on the stored rows -- - * an event already held is upserted, not duplicated. - */ - fun askAgain() { - val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return - - viewModelScope.launch(Dispatchers.IO) { - update { it.copy(isAsking = true) } - ask(loaded.schema, loaded.relays) - } - } - - private suspend fun ask(schema: GroupCuratedSchema, relays: List) { - logger.d("Asking ${relays.size} relay(s) for the queue of ${schema.coordinate()}") - - nostrRepository.queueSynchronizeNostrEvent(requestsFor(schema, relays)) - - askTimeout?.cancel() - askTimeout = viewModelScope.launch(Dispatchers.IO) { - delay(ASK_TIMEOUT) - update { it.copy(isAsking = false) } - } - } - - /** - * Watches the stored rows for the coordinate for as long as the screen - * lives, re-reading the queue whole on every change. Whole rather than - * incrementally because the reading is a merge -- newest per coordinate, - * curated by identifier -- and a merge over a delta is a different reading. - */ - private suspend fun watch(schema: GroupCuratedSchema) { - nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()) - .distinctUntilChanged() - .collect { events -> - val queue = CuratedSuggestion.queueOf(schema, events) - logger.d("${queue.size} suggestion(s) to ${schema.coordinate()} among ${events.size} stored event(s)") - - update { loaded -> withQueue(loaded, queue) } - } - } - - private suspend fun update( - change: (CuratedSuggestionListUIState.Loaded) -> CuratedSuggestionListUIState.Loaded - ) { - stateMutex.withLock { - val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return - curatedSuggestionListUIState = change(loaded) - } - } - - companion object { - private const val TAG = "CuratedSuggestionListViewModel" - - /** - * The schema's relays, else the group's, else the default. See the - * class note for why in that order. Normalised so that two spellings - * of one relay are one request, and only the group's *read* relays, - * since this is a read. - * - * [general] is the group's general relay list as the screen has it, or - * null in a room that has none of that to say. An agreed-but-empty - * list falls through to the default rather than asking nobody: the - * group withdrew where it publishes, not where its lists are read. - */ - internal fun relaysToAsk(schema: GroupCuratedSchema, general: GroupRelayList?): List { - val named = schema.schema.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it)?.url }.distinct() - if (named.isNotEmpty()) return named - - val relays = general?.takeIf { it.isAgreed }?.relays ?: GroupRelaySet.General.defaults() - - return relays.filter { it.read }.map { it.url.url }.distinct() - .ifEmpty { GroupRelaySet.General.defaults().map { it.url.url } } - } - - /** One request per relay, each carrying the NIP's two queries for the coordinate. */ - internal fun requestsFor(schema: GroupCuratedSchema, relays: List): List = - relays.map { relayUrl -> - SynchronizeNostrEventRequest( - purpose = PURPOSE, - synchronizationFilters = schema.queueFilters(), - relayURL = relayUrl, - level = 0, - ) - } - - /** - * The state once [queue] has been read off the stored rows. Something - * arriving means the relays have answered, so the asking ends there; - * nothing arriving leaves it to the timer. - */ - internal fun withQueue( - loaded: CuratedSuggestionListUIState.Loaded, - queue: List, - ): CuratedSuggestionListUIState.Loaded = loaded.copy( - suggestions = queue, - isAsking = loaded.isAsking && queue.isEmpty(), - ) - - /** The purpose the sync rows are filed under, for anything that counts them. */ - const val PURPOSE = "curated-queue" - - /** - * How long an empty queue is "being asked" before it is called empty. - * Longer than the reference client's six seconds because the request - * queues behind the pump's four subscription slots before it goes out. - */ - internal val ASK_TIMEOUT = 10.seconds - - fun factory( - chatRoomId: String, - activeUserPublicKey: HexKey, - relayHint: String?, - identifier: String, - initialCuratedSuggestionListUIState: CuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, - nostrRepository: NostrRepository, - chatRepository: ChatRepository, - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - CuratedSuggestionListViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint, - identifier = identifier, - initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt deleted file mode 100644 index 03847a12..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt +++ /dev/null @@ -1,544 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import co.touchlab.kermit.Logger -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.bech32ToHexOrNull -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSchemaProblem -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch - -/** - * One of the group's curated schemas, edited locally and proposed as one event. - * - * Nothing here is saved. The parts of the schema that are lists -- its fields, - * its rules, its suggesters, its relays -- live on this view model as snapshot - * state so that a bottom sheet opening over the form, or the window turning, - * does not throw an edit away; the identity fields are text and live in the - * composition like every other form's. The button folds both halves into a - * [CuratedSchema], refuses it for any reason a reader would, and otherwise opens - * one signing session over it. The schema changes on every member's device at - * once, when a quorum has signed, or not at all. - * - * ### Edit and replace - * - * Kind 31889 is addressable, so proposing a schema under an identifier the group - * already signed one for *replaces* it -- which is what an edit is, and why the - * identifier is the one thing the editor will not let an edit change. A new - * identifier is a new list, with a queue of its own, and the group's screen - * offers that as adding a schema rather than as editing this one. - * - * The comparison for "nothing changed" is against the schema as the group signed - * it, normalized on both sides, because re-signing an identical schema would put - * a second statement on the record with a newer timestamp: harmless, since the - * kind is replaceable, and still a lie about when the group last decided - * anything about the list. - */ -class EditGroupCuratedSchemaViewModel( - val chatRoomId: String, - val activeUserPublicKey: HexKey, - val relayHint: String?, - /** The `d` of the schema being edited, or null for a new one. */ - val identifier: String?, - initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState, - val chatRepository: ChatRepository, - val frostSigningRepository: FrostSigningRepository, -): ViewModel() { - - var editGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState by mutableStateOf( - initialEditGroupCuratedSchemaUIState - ) - private set - - private val logger = Logger.withTag(TAG) - - val isActionPending: MutableState = mutableStateOf(false) - - /** Who may suggest. Always set: the editor never proposes a schema without one. */ - var visibility: CuratedVisibility by mutableStateOf(CuratedVisibility.Public) - private set - - /** The working copy of the schema's fields, in the order they will be written. */ - val fields = mutableStateListOf() - - /** - * The working copy of the `require-any` rules, each a set of field names. - * - * A rule may hold fewer than two names while it is being built; one that - * still does when the button is pressed says nothing and is dropped rather - * than refused -- see `CuratedSchema.requireAny`. - */ - val requireAny = mutableStateListOf>() - - /** The working copy of the `p` tags: pubkeys besides the group that may suggest. */ - val suggesters = mutableStateListOf() - - /** The working copy of the `relay` tags. */ - val relays = mutableStateListOf() - - /** The field the sheet is open on, or null while it is closed. */ - var editingField: FieldEdit? by mutableStateOf(null) - private set - - /** - * Why the last proposal did not go out, or nothing. Set by the button and - * left showing until the next press, since the button is the thing that - * will clear it. - */ - var problems: List by mutableStateOf(emptyList()) - private set - - /** Why the last relay was refused, or null. Cleared by the next keystroke. */ - var addRelayFailure: AddRelayFailure? by mutableStateOf(null) - private set - - /** Why the last suggester was refused, or null. Cleared by the next keystroke. */ - var addSuggesterFailure: AddSuggesterFailure? by mutableStateOf(null) - private set - - private var isSeeded = false - - /** - * A field on the sheet. [index] is its place in [fields], or null for one - * being added -- which is also what decides whether committing it appends - * or replaces. - */ - data class FieldEdit(val index: Int?, val field: CuratedField) - - enum class AddRelayFailure { - /** Not a `wss://` URL, or one pointing at this machine. */ - NotARelay, - - /** Already named, which is a no-op rather than a mistake. */ - AlreadyListed, - } - - enum class AddSuggesterFailure { - /** Neither an npub nor 64 hex characters. */ - NotAPubkey, - - AlreadyListed, - } - - /** Why a field cannot be committed as it stands. */ - enum class FieldProblem { - NameMissing, - - /** A field name is the key a form collects a value under, and a key is one word. */ - NameHasSpaces, - - /** Another field already answers to that name. */ - NameTaken, - } - - fun initiateEditGroupCuratedSchema() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - editGroupCuratedSchemaUIState = if (localChatRoom == null) { - EditGroupCuratedSchemaUIState.Error("Couldn't find the chat room") - } else { - val existing = identifier?.let { wanted -> - chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == wanted } - } - - if (identifier != null && existing == null) { - // Opened on a schema this device does not hold. Nothing to - // retry and nothing to edit; offering a blank form under that - // identifier would propose a replacement for a list nobody - // here has read. - EditGroupCuratedSchemaUIState.Error("Couldn't find that schema") - } else { - EditGroupCuratedSchemaUIState.Loaded( - localChatRoom = localChatRoom, - existing = existing, - defaultRelays = defaultRelays(chatRoomId), - canSign = localChatRoom.chatRoom.mlsGroupState != null && - frostSigningRepository.canSign(chatRoomId), - ) - } - } - } - } - - /** - * Where a new schema says its list lives: the relays the group has said it - * writes to, since that is where its canonical entries would be read from. - * - * The NIP-65 list where the group has agreed one, else that set's defaults - * -- which is this build's own relay, and the same thing a group opening the - * relay editor for the first time is shown. A schema with no relays is one - * whose suggestions could go anywhere, so the seed is worth getting right. - */ - private suspend fun defaultRelays(chatRoomId: String): List { - val general = chatRepository.groupRelayLists(chatRoomId) - .firstOrNull { it.set == GroupRelaySet.General } - ?: return GroupRelaySet.General.defaults().map { it.url.url } - - return (if (general.isAgreed) general.relays else general.set.defaults()) - .filter { it.write } - .map { it.url.url } - } - - /** - * Fills the working copy from the schema the group signed, once. - * - * An existing schema starts at itself. A new one starts at the two fields - * every list has -- the identifier and the title, which `CuratedSchema.normalized` - * would put back anyway, shown up front so that what the member sees is what - * the group will sign -- and at the group's own relays. - * - * Called from the screen rather than from [initiateEditGroupCuratedSchema], - * because the loader is not the only way a loaded state arrives: a preview - * and a layout test both hand one straight in. Runs once, so the effect that - * calls it re-firing does not undo an edit. - */ - fun seedWorkingCopy(state: EditGroupCuratedSchemaUIState.Loaded) { - if (isSeeded) return - isSeeded = true - - val schema = state.existing?.schema - if (schema != null) { - visibility = schema.visibility ?: CuratedVisibility.Public - fields.addAll(schema.fields) - requireAny.addAll(schema.requireAny) - suggesters.addAll(schema.suggesters) - relays.addAll(schema.relays) - } else { - visibility = CuratedVisibility.Public - fields.addAll(listOf(CuratedField.IDENTIFIER, CuratedField.TITLE)) - relays.addAll(state.defaultRelays) - } - } - - fun selectVisibility(visibility: CuratedVisibility) { - this.visibility = visibility - } - - /** - * Whether [field] is one of the two every list must have. - * - * Decided by the tag it writes to, not its name, because that is what the - * NIP's rule is about: a list needs a field writing to `d` and one writing - * to `title`, whatever they are called. Such a field cannot be removed or - * made optional here -- `CuratedSchema.normalized` would only put it back. - */ - fun isMandatory(field: CuratedField): Boolean = - CuratedField.MANDATORY.any { (tag, _) -> field.tag() == tag } - - /** Opens the sheet on a blank field. Text, because most fields are. */ - fun startAddingField() { - editingField = FieldEdit( - index = null, - field = CuratedField(name = "", type = CuratedFieldType.Text, isRequired = false), - ) - } - - fun startEditingField(index: Int) { - fields.getOrNull(index)?.let { editingField = FieldEdit(index = index, field = it) } - } - - fun dismissFieldEditor() { - editingField = null - } - - /** - * Puts [field] into the working copy at the place the sheet was opened on, - * or says why it cannot. - * - * Null means it went in and the sheet is closed. A rename follows through to - * the rules that named the field, since a rule naming a field that no longer - * exists would silently never be satisfied. - */ - fun commitField(field: CuratedField): FieldProblem? { - val edit = editingField ?: return null - - val name = field.name.trim() - if (name.isEmpty()) return FieldProblem.NameMissing - if (name.any { it.isWhitespace() }) return FieldProblem.NameHasSpaces - if (fields.withIndex().any { (index, other) -> index != edit.index && other.name == name }) { - return FieldProblem.NameTaken - } - - val committed = field.copy(name = name) - - if (edit.index == null) { - fields.add(committed) - } else { - val previous = fields[edit.index] - fields[edit.index] = committed - if (previous.name != name) { - requireAny.indices.forEach { group -> - requireAny[group] = requireAny[group].map { if (it == previous.name) name else it } - } - } - } - - editingField = null - return null - } - - /** Drops the field at [index] and takes it out of any rule that named it. */ - fun removeField(index: Int) { - val removed = fields.getOrNull(index) ?: return - if (isMandatory(removed)) return - - fields.removeAt(index) - requireAny.indices.forEach { group -> - requireAny[group] = requireAny[group].filterNot { it == removed.name } - } - editingField = null - } - - fun addRequireAnyGroup() { - requireAny.add(emptyList()) - } - - fun toggleRequireAny(group: Int, fieldName: String) { - val names = requireAny.getOrNull(group) ?: return - requireAny[group] = if (fieldName in names) names - fieldName else names + fieldName - } - - fun removeRequireAnyGroup(group: Int) { - if (group in requireAny.indices) requireAny.removeAt(group) - } - - /** - * Adds [input] to the suggesters, or says why it did not. - * - * True when the row appeared, which is the caller's cue to clear the field. - */ - fun addSuggester(input: String): Boolean { - val pubkey = pubkeyOrNull(input) ?: run { - addSuggesterFailure = AddSuggesterFailure.NotAPubkey - return false - } - - if (pubkey in suggesters) { - addSuggesterFailure = AddSuggesterFailure.AlreadyListed - return false - } - - suggesters.add(pubkey) - addSuggesterFailure = null - return true - } - - fun removeSuggester(pubkey: HexKey) { - suggesters.remove(pubkey) - } - - fun clearAddSuggesterFailure() { - addSuggesterFailure = null - } - - /** - * Adds [url] to the relays, or says why it did not. - * - * Held to the same rule as the group's relay lists -- `wss://`, not this - * machine -- rather than to the NIP's looser one, because this is a relay - * the group is about to sign for strangers to publish to. See - * `GroupRelaySet.relayUrlOrNull`. - */ - fun addRelay(url: String): Boolean { - val relay = GroupRelaySet.relayUrlOrNull(url) ?: run { - addRelayFailure = AddRelayFailure.NotARelay - return false - } - - // Compared normalized, because a relay signed into an existing schema is - // kept as it was written and the normalizer adds a trailing slash: the - // same host with and without one is one relay, not two. - if (relays.any { GroupRelaySet.relayUrlOrNull(it) == relay }) { - addRelayFailure = AddRelayFailure.AlreadyListed - return false - } - - relays.add(relay.url) - addRelayFailure = null - return true - } - - fun removeRelay(url: String) { - relays.remove(url) - } - - fun clearAddRelayFailure() { - addRelayFailure = null - } - - /** - * The schema as the form has it: the text fields trimmed and the lists as - * they stand, with a rule too short to mean anything left out. - */ - fun draft( - identifier: String, - title: String, - name: String, - description: String, - picture: String, - domain: String, - ): CuratedSchema = CuratedSchema( - identifier = identifier.trim(), - title = title.trim(), - name = name.trim(), - description = description.trim(), - visibility = visibility, - picture = picture.trim().ifEmpty { null }, - domain = domain.trim().ifEmpty { null }?.let(CuratedSchemaEvent::normalizeDomain), - fields = fields.toList(), - requireAny = requireAny.filter { it.size > 1 }, - suggesters = suggesters.toList(), - relays = relays.toList(), - ) - - /** - * Opens a session over the schema, or says why not. - * - * Three refusals before anything is proposed and each is the caller's to - * say: a schema a reader would reject, which is [onInvalid] with the - * reasons; an edit that changed nothing, which is [onNothingToPropose]; and - * a proposal that could not be opened, which is [onFailure]. All of them - * stay on the form with the edits still in it. - */ - fun proposeSchema( - localChatRoom: LocalChatRoom, - existing: GroupCuratedSchema?, - identifier: String, - title: String, - name: String, - description: String, - picture: String, - domain: String, - onSuccess: (sessionId: String) -> Unit, - onInvalid: () -> Unit, - onNothingToPropose: () -> Unit, - onFailure: () -> Unit - ) { - // Guard against double submits. Two sessions over two versions of one - // schema would leave the list's form decided by whichever quorum - // finished last. - if (isActionPending.value) return - - val schema = draft( - // The identifier of an existing schema is the coordinate every - // suggestion to it replies to, so an edit keeps it whatever the - // field says -- and the field is read-only to say so. - identifier = existing?.identifier ?: identifier, - title = title, - name = name, - description = description, - picture = picture, - domain = domain, - ) - - problems = schema.problems() - if (problems.isNotEmpty()) { - onInvalid.invoke() - return - } - - if (existing != null && existing.schema == schema.normalized()) { - onNothingToPropose.invoke() - return - } - - isActionPending.value = true - - val template = GroupCuratedSchema.template(schema) - - viewModelScope.launch(Dispatchers.IO) { - val session = runCatching { - frostSigningRepository.proposeSigning( - localChatRoom = localChatRoom, - userPublicKey = activeUserPublicKey, - kind = template.kind, - tags = template.tags, - content = template.content, - ) - }.onFailure { error -> - logger.e("Failed to propose a curated schema for $chatRoomId", error) - }.getOrNull() - - viewModelScope.launch(Dispatchers.Main) { - if (session != null) { - onSuccess.invoke(session.id) - } else { - onFailure.invoke() - } - } - - isActionPending.value = false - } - } - - companion object { - private const val TAG = "EditGroupCuratedSchemaViewModel" - - /** - * [input] as a pubkey the schema could name, or null. - * - * An npub -- with or without a `nostr:` in front, the way it is pasted - * from most clients -- or 64 hex characters in either case. Nothing else, - * and in particular not a NIP-05 address, since resolving one is a - * network round trip and this is a field on a form. - */ - fun pubkeyOrNull(input: String): HexKey? { - val trimmed = input.trim().removePrefix("nostr:") - - val hex = if (trimmed.startsWith("npub1", ignoreCase = true)) { - trimmed.bech32ToHexOrNull() - } else { - trimmed.lowercase() - } - - return hex?.takeIf { candidate -> - candidate.length == 64 && candidate.all { it in '0'..'9' || it in 'a'..'f' } - } - } - - fun factory( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - identifier: String?, - initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState = - EditGroupCuratedSchemaUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - EditGroupCuratedSchemaViewModel( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - identifier = identifier, - initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt deleted file mode 100644 index ff8d81f6..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt +++ /dev/null @@ -1,161 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import co.touchlab.kermit.Logger -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch - -/** - * Asks the group to sign what it says about itself on nostr. - * - * The room's id is the pubkey the group signs as, so the kind:0 this proposes is - * the profile the rest of nostr would read for that key -- authored by the group - * rather than by whoever filled the form in. Nothing is saved when the button is - * pressed: what goes out is a proposal, and the profile changes on every member's - * device at once when a quorum has signed it. - */ -class EditGroupNostrProfileViewModel( - val chatRoomId: String, - val activeUserPublicKey: HexKey, - val relayHint: String?, - initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState, - val chatRepository: ChatRepository, - val frostSigningRepository: FrostSigningRepository, -): ViewModel() { - - var editGroupNostrProfileUIState: EditGroupNostrProfileUIState by mutableStateOf( - initialEditGroupNostrProfileUIState - ) - private set - - private val logger = Logger.withTag(TAG) - - val isActionPending: MutableState = mutableStateOf(false) - - fun initiateEditGroupNostrProfile() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - editGroupNostrProfileUIState = if (localChatRoom == null) { - EditGroupNostrProfileUIState.Error("Couldn't find the chat room") - } else { - EditGroupNostrProfileUIState.Loaded( - localChatRoom = localChatRoom, - groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), - // A NIP-17 room has no key of its own to sign as, so it has no - // nostr identity to describe -- the same condition the group - // detail screen gates the entry point on. - canSign = localChatRoom.chatRoom.mlsGroupState != null && - frostSigningRepository.canSign(chatRoomId), - ) - } - } - } - - /** - * Opens a session over one kind:0 for the group's own key. - * - * A blank field is not a field left alone: it clears what the group had said, - * because that is the only way to unsay it. [GroupNostrProfile.template] hands - * every field to `MetadataEvent`, whose rule for these arguments is that empty - * deletes the key -- and it builds on the group's newest profile, so a field - * this form does not offer is carried across rather than wiped. - * - * The fields are not cleared on success, unlike the add forms': a profile is - * standing state rather than a thing being added, and this screen is replaced - * by the signing session anyway. - */ - fun proposeNostrProfile( - localChatRoom: LocalChatRoom, - groupNostrProfile: GroupNostrProfile?, - nameField: TextFieldState, - aboutField: TextFieldState, - pictureField: TextFieldState, - websiteField: TextFieldState, - nip05Field: TextFieldState, - lud16Field: TextFieldState, - onSuccess: (sessionId: String) -> Unit, - onFailure: () -> Unit - ) { - // Guard against double submits from repeated taps. A second session over a - // second kind:0 would leave the group's profile decided by whichever - // quorum finished last. - if (isActionPending.value) return - isActionPending.value = true - - val template = GroupNostrProfile.template( - latest = groupNostrProfile, - name = nameField.text.toString().trim(), - about = aboutField.text.toString().trim(), - picture = pictureField.text.toString().trim(), - website = websiteField.text.toString().trim(), - nip05 = nip05Field.text.toString().trim(), - lud16 = lud16Field.text.toString().trim(), - ) - - viewModelScope.launch(Dispatchers.IO) { - val session = runCatching { - frostSigningRepository.proposeSigning( - localChatRoom = localChatRoom, - userPublicKey = activeUserPublicKey, - kind = template.kind, - tags = template.tags, - content = template.content, - ) - }.onFailure { error -> - logger.e("Failed to propose a nostr profile for $chatRoomId", error) - }.getOrNull() - - viewModelScope.launch(Dispatchers.Main) { - if (session != null) { - onSuccess.invoke(session.id) - } else { - onFailure.invoke() - } - } - - isActionPending.value = false - } - } - - companion object { - private const val TAG = "EditGroupNostrProfileViewModel" - - fun factory( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState = - EditGroupNostrProfileUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - EditGroupNostrProfileViewModel( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt deleted file mode 100644 index fbae1c94..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt +++ /dev/null @@ -1,299 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import co.touchlab.kermit.Logger -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.EditGroupRelaysUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch - -/** - * The group's relay lists, edited locally and proposed together. - * - * Nothing here is saved. The working copy lives in [working] until the button is - * pressed, and what the button does is open one signing session over the sets that - * changed -- so the group's relay lists change on every member's device at once, - * when a quorum has signed, or not at all. - * - * ### Why one session rather than one per set - * - * Wisp publishes a tab at a time because a person's client signs for itself and - * there is nothing to coordinate. Here every signature costs a quorum's attention, - * and four separate sessions for one sitting at one screen would ask for it four - * times over what is plainly a single decision: where this group lives. - * - * The batch carries **only the sets that differ**, which is what keeps that honest. - * Re-signing a list nobody touched would put a second, identical statement on the - * record with a newer timestamp -- harmless, since these are replaceable, and still - * a lie about when the group last decided anything. - * - * `FrostSigningManager` documents the cost of batching: a batch is all-or-nothing - * and so only as available as its worst item. These four items are the same size, - * the same shape and signed by the same quorum in the same second, so there is no - * worst one to be dragged down by. - */ -class EditGroupRelaysViewModel( - val chatRoomId: String, - val activeUserPublicKey: HexKey, - val relayHint: String?, - initialEditGroupRelaysUIState: EditGroupRelaysUIState, - val chatRepository: ChatRepository, - val frostSigningRepository: FrostSigningRepository, -): ViewModel() { - - var editGroupRelaysUIState: EditGroupRelaysUIState by mutableStateOf( - initialEditGroupRelaysUIState - ) - private set - - private val logger = Logger.withTag(TAG) - - val isActionPending: MutableState = mutableStateOf(false) - - /** - * The working copy: what the lists would be if the group agreed. - * - * A snapshot map so the editor recomposes as rows are added and removed, and - * held on the view model rather than in the composition so that switching tabs - * -- which is a pager page changing -- does not throw away an edit. - */ - val working = mutableStateMapOf>() - - /** Which set the editor is showing, and so what the add field adds to. */ - var selectedSet: GroupRelaySet by mutableStateOf(GroupRelaySet.entries.first()) - private set - - /** - * Why the last add did not happen, or null. Cleared by the next keystroke. - * - * Named rather than silent: Wisp's add button does nothing at all for a URL it - * refuses, and "nothing happened" is indistinguishable from a missed tap. - */ - var addFailure: AddFailure? by mutableStateOf(null) - private set - - enum class AddFailure { - /** Not a `wss://` URL, or one pointing at this machine. */ - NotARelay, - - /** Already in this set, which is a no-op rather than a mistake. */ - AlreadyListed, - } - - fun initiateEditGroupRelays() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - editGroupRelaysUIState = if (localChatRoom == null) { - EditGroupRelaysUIState.Error("Couldn't find the chat room") - } else { - EditGroupRelaysUIState.Loaded( - localChatRoom = localChatRoom, - signed = chatRepository.groupRelayLists(chatRoomId), - canSign = localChatRoom.chatRoom.mlsGroupState != null && - frostSigningRepository.canSign(chatRoomId), - ) - } - } - } - - /** - * Fills the working copy from what the group signed, leaving edits alone. - * - * A set the group has agreed starts at its list; one it has not starts at the - * set's defaults, which is what puts this build's own relay in front of a group - * setting up for the first time rather than an empty list. See - * `GroupRelaySet.defaults`. - * - * Called from the screen rather than from [initiateEditGroupRelays], because - * the loader is not the only way a loaded state arrives -- a preview and a - * layout test both hand one straight in, and seeding only on the load path gave - * both of them an editor with nothing in it while the app worked fine. The - * screen has the state either way. - * - * **Only fills what is missing**, so running again after the member has typed - * something does not undo it. That matters because the effect that calls this - * is keyed on a state the view model can re-emit. - */ - fun seedWorkingCopy(signed: List) { - signed.forEach { list -> - if (list.set in working) return@forEach - - working[list.set] = if (list.isAgreed) list.relays else list.set.defaults() - } - } - - fun selectSet(set: GroupRelaySet) { - selectedSet = set - addFailure = null - } - - fun clearAddFailure() { - addFailure = null - } - - fun relaysOf(set: GroupRelaySet): List = working[set] ?: emptyList() - - /** - * Adds [url] to the selected set, or says why it did not. - * - * True when the row appeared, which is the caller's cue to clear the field. - */ - fun addRelay(url: String): Boolean { - val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run { - addFailure = AddFailure.NotARelay - return false - } - - val current = relaysOf(selectedSet) - if (current.any { it.url == relayUrl }) { - addFailure = AddFailure.AlreadyListed - return false - } - - working[selectedSet] = current + GroupRelay(relayUrl) - addFailure = null - return true - } - - fun removeRelay(set: GroupRelaySet, relay: GroupRelay) { - working[set] = relaysOf(set).filterNot { it.url == relay.url } - } - - /** - * Turns a NIP-65 marker on or off, refusing to turn off the last one. - * - * A relay that is neither read nor write has no tag in NIP-65 -- see - * `GroupRelaySet.template` -- so the state does not exist to be put the group's - * signature on. What it would mean is that the relay is not in the list, and - * removing it is the row's own button. - */ - fun toggleRead(set: GroupRelaySet, relay: GroupRelay) = - toggle(set, relay) { it.copy(read = !it.read) } - - fun toggleWrite(set: GroupRelaySet, relay: GroupRelay) = - toggle(set, relay) { it.copy(write = !it.write) } - - private fun toggle( - set: GroupRelaySet, - relay: GroupRelay, - change: (GroupRelay) -> GroupRelay, - ) { - working[set] = relaysOf(set).map { - if (it.url != relay.url) return@map it - - change(it).takeIf { changed -> changed.read || changed.write } ?: it - } - } - - /** - * The sets whose working copy says something different to what the group - * signed, in the order the editor shows them. - * - * Order-sensitive on purpose. A relay list is written in the order it is read - * back, and a member who moved a relay to the front meant to; comparing as sets - * would call that no change and quietly refuse to propose it. - * - * A set the group has never agreed counts as changed as soon as it holds - * anything, so a first-time group's seeded defaults are a real proposal rather - * than an accident, and an empty one it has never agreed counts as unchanged -- - * there is nothing to say. - */ - fun changedSets(signed: List): List = - signed.filter { list -> relaysOf(list.set) != list.relays } - .filterNot { list -> !list.isAgreed && relaysOf(list.set).isEmpty() } - .map { it.set } - - /** - * Opens one session over every set that changed. - * - * [onNothingToPropose] rather than a session over nothing: - * `proposeSigningBatch` refuses an empty batch outright, and a member who has - * changed nothing is asking a question rather than making a mistake. - */ - fun proposeRelayLists( - localChatRoom: LocalChatRoom, - signed: List, - onSuccess: (sessionId: String) -> Unit, - onNothingToPropose: () -> Unit, - onFailure: () -> Unit - ) { - // Guard against double submits. Two sessions over two versions of one relay - // list would leave the group's answer decided by whichever quorum finished - // last. - if (isActionPending.value) return - - val changed = changedSets(signed) - if (changed.isEmpty()) { - onNothingToPropose.invoke() - return - } - - isActionPending.value = true - - val templates = changed.map { set -> set.template(relaysOf(set)) } - - viewModelScope.launch(Dispatchers.IO) { - val session = runCatching { - frostSigningRepository.proposeSigningBatch( - localChatRoom = localChatRoom, - userPublicKey = activeUserPublicKey, - events = templates, - ) - }.onFailure { error -> - logger.e("Failed to propose relay lists for $chatRoomId", error) - }.getOrNull() - - viewModelScope.launch(Dispatchers.Main) { - if (session != null) { - onSuccess.invoke(session.id) - } else { - onFailure.invoke() - } - } - - isActionPending.value = false - } - } - - companion object { - private const val TAG = "EditGroupRelaysViewModel" - - fun factory( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialEditGroupRelaysUIState: EditGroupRelaysUIState = - EditGroupRelaysUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - EditGroupRelaysViewModel( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - initialEditGroupRelaysUIState = initialEditGroupRelaysUIState, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt deleted file mode 100644 index b9412b92..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedEntriesViewModel.kt +++ /dev/null @@ -1,65 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState - -/** - * The entries the group has accepted into its lists, read once for the screen - * that shows them. One read rather than a watch, for the reason - * `GroupNostrProfileViewModel` gives. Nothing here asks whether this device - * can sign, because nothing on the screen would change if it could. - */ -class GroupCuratedEntriesViewModel( - val chatRoomId: String, - initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState, - val chatRepository: ChatRepository, -): ViewModel() { - - var groupCuratedEntriesUIState: GroupCuratedEntriesUIState by mutableStateOf( - initialGroupCuratedEntriesUIState - ) - private set - - fun initiate() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - groupCuratedEntriesUIState = if (localChatRoom == null) { - GroupCuratedEntriesUIState.Error("Couldn't find the group") - } else { - GroupCuratedEntriesUIState.Loaded( - localChatRoom = localChatRoom, - groupCuratedEntries = chatRepository.groupCuratedEntries(chatRoomId), - ) - } - } - } - - companion object { - fun factory( - chatRoomId: String, - initialGroupCuratedEntriesUIState: GroupCuratedEntriesUIState = - GroupCuratedEntriesUIState.Loading, - chatRepository: ChatRepository, - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - GroupCuratedEntriesViewModel( - chatRoomId = chatRoomId, - initialGroupCuratedEntriesUIState = initialGroupCuratedEntriesUIState, - chatRepository = chatRepository, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt deleted file mode 100644 index e3988d9b..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupCuratedSchemasViewModel.kt +++ /dev/null @@ -1,66 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState - -/** - * The schemas of the lists the group curates, read once for the screen that - * shows them. One read rather than a watch, for the reason - * `GroupNostrProfileViewModel` gives. - */ -class GroupCuratedSchemasViewModel( - val chatRoomId: String, - initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState, - val chatRepository: ChatRepository, -): ViewModel() { - - var groupCuratedSchemasUIState: GroupCuratedSchemasUIState by mutableStateOf( - initialGroupCuratedSchemasUIState - ) - private set - - fun initiate() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - groupCuratedSchemasUIState = if (localChatRoom == null) { - GroupCuratedSchemasUIState.Error("Couldn't find the group") - } else { - GroupCuratedSchemasUIState.Loaded( - localChatRoom = localChatRoom, - groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId), - canSign = localChatRoom.chatRoom.mlsGroupState != null && - chatRepository.canSign(chatRoomId), - ) - } - } - } - - companion object { - fun factory( - chatRoomId: String, - initialGroupCuratedSchemasUIState: GroupCuratedSchemasUIState = - GroupCuratedSchemasUIState.Loading, - chatRepository: ChatRepository, - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - GroupCuratedSchemasViewModel( - chatRoomId = chatRoomId, - initialGroupCuratedSchemasUIState = initialGroupCuratedSchemasUIState, - chatRepository = chatRepository, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt deleted file mode 100644 index 2d7b4315..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupNostrProfileViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.view.state.GroupNostrProfileUIState - -/** - * The group's nostr profile, read once for the screen that shows it. - * - * One read rather than a watch, the way the group's screen reads it: a profile - * changes when a quorum signs a new one, which lands as a group event this - * device processes, and the screen is re-entered -- and re-read -- on the way - * back from the signing session that made it. - */ -class GroupNostrProfileViewModel( - val chatRoomId: String, - initialGroupNostrProfileUIState: GroupNostrProfileUIState, - val chatRepository: ChatRepository, -): ViewModel() { - - var groupNostrProfileUIState: GroupNostrProfileUIState by mutableStateOf( - initialGroupNostrProfileUIState - ) - private set - - fun initiate() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - groupNostrProfileUIState = if (localChatRoom == null) { - GroupNostrProfileUIState.Error("Couldn't find the group") - } else { - GroupNostrProfileUIState.Loaded( - localChatRoom = localChatRoom, - groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), - // A NIP-17 room is excluded by the first half: it has no key - // of its own to sign as, so nothing to hold a share of. - canSign = localChatRoom.chatRoom.mlsGroupState != null && - chatRepository.canSign(chatRoomId), - ) - } - } - } - - companion object { - fun factory( - chatRoomId: String, - initialGroupNostrProfileUIState: GroupNostrProfileUIState = - GroupNostrProfileUIState.Loading, - chatRepository: ChatRepository, - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - GroupNostrProfileViewModel( - chatRoomId = chatRoomId, - initialGroupNostrProfileUIState = initialGroupNostrProfileUIState, - chatRepository = chatRepository, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt deleted file mode 100644 index 20fae7f9..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupPostsViewModel.kt +++ /dev/null @@ -1,64 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.view.state.GroupPostsUIState - -/** - * The group's posts, read once for the screen that shows them, with the - * profile that bylines them. One read rather than a watch, for the reason - * `GroupNostrProfileViewModel` gives. - */ -class GroupPostsViewModel( - val chatRoomId: String, - initialGroupPostsUIState: GroupPostsUIState, - val chatRepository: ChatRepository, -): ViewModel() { - - var groupPostsUIState: GroupPostsUIState by mutableStateOf(initialGroupPostsUIState) - private set - - fun initiate() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - groupPostsUIState = if (localChatRoom == null) { - GroupPostsUIState.Error("Couldn't find the group") - } else { - GroupPostsUIState.Loaded( - localChatRoom = localChatRoom, - groupPosts = chatRepository.groupPosts(chatRoomId), - groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), - canSign = localChatRoom.chatRoom.mlsGroupState != null && - chatRepository.canSign(chatRoomId), - ) - } - } - } - - companion object { - fun factory( - chatRoomId: String, - initialGroupPostsUIState: GroupPostsUIState = GroupPostsUIState.Loading, - chatRepository: ChatRepository, - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - GroupPostsViewModel( - chatRoomId = chatRoomId, - initialGroupPostsUIState = initialGroupPostsUIState, - chatRepository = chatRepository, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt deleted file mode 100644 index 8c6c4152..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/GroupRelaysViewModel.kt +++ /dev/null @@ -1,62 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.view.state.GroupRelaysUIState - -/** - * The group's relay lists, read once for the screen that shows them. One read - * rather than a watch, for the reason `GroupNostrProfileViewModel` gives. - */ -class GroupRelaysViewModel( - val chatRoomId: String, - initialGroupRelaysUIState: GroupRelaysUIState, - val chatRepository: ChatRepository, -): ViewModel() { - - var groupRelaysUIState: GroupRelaysUIState by mutableStateOf(initialGroupRelaysUIState) - private set - - fun initiate() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - groupRelaysUIState = if (localChatRoom == null) { - GroupRelaysUIState.Error("Couldn't find the group") - } else { - GroupRelaysUIState.Loaded( - localChatRoom = localChatRoom, - groupRelayLists = chatRepository.groupRelayLists(chatRoomId), - canSign = localChatRoom.chatRoom.mlsGroupState != null && - chatRepository.canSign(chatRoomId), - ) - } - } - } - - companion object { - fun factory( - chatRoomId: String, - initialGroupRelaysUIState: GroupRelaysUIState = GroupRelaysUIState.Loading, - chatRepository: ChatRepository, - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - GroupRelaysViewModel( - chatRoomId = chatRoomId, - initialGroupRelaysUIState = initialGroupRelaysUIState, - chatRepository = chatRepository, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt deleted file mode 100644 index 87f8f029..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt +++ /dev/null @@ -1,158 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import co.touchlab.kermit.Logger -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupEventProposal -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.ProposeGroupEventUIState -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.launch - -/** - * Asks the group to sign an event a member pasted. - * - * Nothing is built here; the paste is the event. What this decides is whether - * the group should be asked at all -- `GroupEventProposal.read` refuses a paste - * the group's screen could never show, so a quorum is not spent on an event that - * would be signed, filed and seen by nobody -- and it hands the kind, tags and - * content to the same `proposeSigning` every typed editor uses. The author and - * the time are the session's to fill in, which is how a pasted `pubkey` or - * `created_at` comes to be ignored: they are never passed along. - * - * Where the event lands is decided by nothing here. `FrostSigningManager.complete` - * files every signed event as a `GroupSignedEvent`, and the group's screen reads - * its profile, posts, relay lists and schemas off those by kind. A pasted kind 0 - * becomes the profile the same way a kind 0 from the profile form does, because - * by the time either is signed there is no telling them apart. - */ -class ProposeGroupEventViewModel( - val chatRoomId: String, - val activeUserPublicKey: HexKey, - val relayHint: String?, - initialProposeGroupEventUIState: ProposeGroupEventUIState, - val chatRepository: ChatRepository, - val frostSigningRepository: FrostSigningRepository, -): ViewModel() { - - var proposeGroupEventUIState: ProposeGroupEventUIState by mutableStateOf( - initialProposeGroupEventUIState - ) - private set - - private val logger = Logger.withTag(TAG) - - val isActionPending: MutableState = mutableStateOf(false) - - fun initiateProposeGroupEvent() { - viewModelScope.launch(Dispatchers.IO) { - val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) - - proposeGroupEventUIState = if (localChatRoom == null) { - ProposeGroupEventUIState.Error("Couldn't find the chat room") - } else { - ProposeGroupEventUIState.Loaded( - localChatRoom = localChatRoom, - groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), - // A NIP-17 room has no key of its own to sign as, so it has no - // statements to make -- the same condition the group detail - // screen gates the entry point on. - canSign = localChatRoom.chatRoom.mlsGroupState != null && - frostSigningRepository.canSign(chatRoomId), - ) - } - } - } - - /** - * Opens a session over the pasted event, or says why not. - * - * [onRefused] carries the reading's own reason, which the screen has been - * showing under the paste all along; it is repeated here so pressing the - * button on a refused paste is answered rather than ignored. Both refusals - * and a failed proposal stay on the paste: the JSON is the thing worth - * keeping. - */ - fun proposeEvent( - localChatRoom: LocalChatRoom, - json: String, - onSuccess: (sessionId: String) -> Unit, - onRefused: (GroupEventProposal.Refused) -> Unit, - onFailure: () -> Unit - ) { - // Guard against double submits. A pasted kind:1 accumulates like any - // other post, so a second session over the same paste is a second post - // on the record forever. - if (isActionPending.value) return - - val ready = when (val reading = GroupEventProposal.read(json)) { - is GroupEventProposal.Ready -> reading - is GroupEventProposal.Refused -> { - onRefused.invoke(reading) - return - } - } - - isActionPending.value = true - - viewModelScope.launch(Dispatchers.IO) { - val session = runCatching { - frostSigningRepository.proposeSigning( - localChatRoom = localChatRoom, - userPublicKey = activeUserPublicKey, - kind = ready.kind, - tags = ready.tags, - content = ready.content, - ) - }.onFailure { error -> - logger.e("Failed to propose a pasted kind ${ready.kind} event for $chatRoomId", error) - }.getOrNull() - - viewModelScope.launch(Dispatchers.Main) { - if (session != null) { - onSuccess.invoke(session.id) - } else { - onFailure.invoke() - } - } - - isActionPending.value = false - } - } - - companion object { - private const val TAG = "ProposeGroupEventViewModel" - - fun factory( - activeUserPublicKey: HexKey, - chatRoomId: String, - relayHint: String?, - initialProposeGroupEventUIState: ProposeGroupEventUIState = - ProposeGroupEventUIState.Loading, - chatRepository: ChatRepository, - frostSigningRepository: FrostSigningRepository - ): ViewModelProvider.Factory = viewModelFactory { - initializer { - ProposeGroupEventViewModel( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - initialProposeGroupEventUIState = initialProposeGroupEventUIState, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt deleted file mode 100644 index 381447b2..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt +++ /dev/null @@ -1,34 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema - -sealed interface AcceptCuratedSuggestionUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** The list the entry would go into, as the group last signed it. */ - val schema: GroupCuratedSchema, - /** - * The suggestion being taken up, as the queue screen read it: the entry - * keyed by the schema's fields, who suggested it, and whether the group - * already has. The form starts as this, and the canonical entry points - * back at it. - */ - val suggestion: CuratedSuggestion, - /** - * Whether this device holds a share of the group's key. - * - * False leaves the form writable and the button inert, for the reason - * the other editors give: reading and editing cost nothing, and only a - * share-holder can open the session that would sign. - */ - val canSign: Boolean = false, - ): AcceptCuratedSuggestionUIState - - data class Error( - val message: String - ): AcceptCuratedSuggestionUIState - - data object Loading: AcceptCuratedSuggestionUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt deleted file mode 100644 index 4bcbffb3..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt +++ /dev/null @@ -1,33 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupNostrProfile - -sealed interface AddGroupPostUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * Who the group says it is, for the byline over the draft. - * - * A post is signed by the group and read as the group, so the composer - * shows whose voice is being written in -- which is not the member typing. - * Null in a group that has not described itself, and the byline falls back - * to the room. - */ - val groupNostrProfile: GroupNostrProfile? = null, - /** - * Whether this device holds a share of the group's key. - * - * False leaves the draft writable and the button inert, for the same reason - * the other two editors do: reading and composing cost nothing, and only a - * share-holder can open the session that would sign it. - */ - val canSign: Boolean = false, - ): AddGroupPostUIState - - data class Error( - val message: String - ): AddGroupPostUIState - - data object Loading: AddGroupPostUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt index 277d2c9e..bd472a3b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt @@ -2,27 +2,12 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupRelayList sealed interface BroadcastGroupSignedEventUIState { data class Loaded( val localChatRoom: LocalChatRoom, /** The event to send, as the group signed it. What goes on the wire is this, byte for byte. */ val signedEvent: GroupSignedEvent, - /** - * Where the group has said it lives, which is what the relay picker starts - * from: the General list's write relays when the group has agreed one, and - * never a relay the group has blocked. See - * `BroadcastGroupSignedEventViewModel.defaultRelaysFor`. - */ - val relayLists: List = emptyList(), - /** - * The lists the group curates, for an entry into one of them: the NIP - * says a canonical entry goes to its schema's relays, and the entry - * names its schema by coordinate rather than carrying the relays itself. - */ - val schemas: List = emptyList(), ): BroadcastGroupSignedEventUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 7222beae..80f08814 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -7,11 +7,6 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.nostr.GroupRelayList sealed interface ChatRoomDetailUIState { data class Loaded( @@ -55,78 +50,6 @@ sealed interface ChatRoomDetailUIState { * really is a subgroup. */ val parentChatRoomId: HexKey? = null, - /** - * The kind:0 the group signed about itself, or null while it has signed - * none. - * - * Its row sits under the room's identity and above its work, because that - * is what it is: the name and the picture the rest of nostr sees on the - * key the signing key row names. The row shows the name and the address; - * the profile itself is read on `GroupNostrProfileScreen`. Null is an - * ordinary state and the row says so -- a group has an identity from the - * moment it has a key and describes it whenever somebody gets round to it. - */ - val groupNostrProfile: GroupNostrProfile? = null, - /** - * Where the group has said it lives on nostr, one entry per relay set. - * - * Beside the profile because they are the same kind of fact -- the group's - * own account of itself, signed by the group -- and read in the same breath - * by anybody checking whether the group is reachable. The row counts how - * many the group has agreed; the lists are read on `GroupRelaysScreen`. - * - * Empty only in a room that has none of this to say. Every other case has - * four entries, some of them unsigned; see `ChatRepository.groupRelayLists`. - */ - val groupRelayLists: List = emptyList(), - /** - * What the group has said publicly, as itself, newest first. - * - * Under the relay lists because it is the thing they are *for*: the profile - * says who the group is, the relay lists say where to find it, and these - * are what a reader would find there. The row counts them; they are read - * on `GroupPostsScreen`. Empty in a group that has said nothing, which is - * every group until somebody proposes one. - */ - val groupPosts: List = emptyList(), - /** - * The lists this group curates, as the schemas it signed for them, most - * recently signed first. - * - * Under the posts because a schema is the other thing a group publishes - * for strangers to answer: a post is the group speaking, a schema is the - * group asking -- for entries to a list it will then curate. The row - * counts them; they are read on `GroupCuratedSchemasScreen`. Empty in a - * group that curates nothing, which is every group until somebody - * proposes a schema. - */ - val groupCuratedSchemas: List = emptyList(), - /** - * The entries this group has accepted into the lists it curates, most - * recently signed first. - * - * Under the schemas because they are what a schema is *for*: the schema - * asks, strangers suggest, and these are what the group decided to - * stand behind. The row counts them; they are read on - * `GroupCuratedEntriesScreen`. Empty in a group that has accepted - * nothing, which is every group until a quorum signs an entry. - */ - val groupCuratedEntries: List = emptyList(), - /** - * Whether this device could sign an event for the group, which on this - * screen gates the paste at the end of the identity block. Each of the - * five screens the rows open reads the same capability for its own gate. - * - * The same capability [canAddSubgroup] wants, and wanted for the same - * reason: proposing anything in the group's name is a signature by the - * group, and `FrostSigningManager.proposeSigningBatch` throws for a - * device holding no share of the key. It is not about permission -- any - * member with a share may propose -- and the quorum is what decides. - * - * A NIP-17 room is excluded by the same condition, since it has no key of - * its own to sign as and so no nostr identity to describe. - */ - val canEditNostrProfile: Boolean = false, /** * Whether this device could open a subgroup here at all. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt deleted file mode 100644 index 5d41d950..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt +++ /dev/null @@ -1,40 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema - -sealed interface CuratedSuggestionListUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** The list whose queue this is, as the group last signed it. */ - val schema: GroupCuratedSchema, - /** - * Where the queue was read from: the relays the schema names, or the - * group's own where it names none. On the screen because a queue read - * from the wrong relays is an empty queue with no way to tell it from - * a list nobody has written to. - */ - val relays: List = emptyList(), - /** Everything suggested to the list, newest first. */ - val suggestions: List = emptyList(), - /** - * Whether the relays are still being asked. - * - * A pull through the sync queue has no end-of-stored-events the screen - * can see, so this is bounded by time rather than by an answer: true - * from the request going out until the first suggestion lands or the - * wait runs out. While it is true and nothing has arrived, the screen - * is looking rather than empty. - */ - val isAsking: Boolean = false, - ): CuratedSuggestionListUIState { - val curatedCount: Int get() = suggestions.count { it.isCurated } - } - - data class Error( - val message: String - ): CuratedSuggestionListUIState - - data object Loading: CuratedSuggestionListUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt deleted file mode 100644 index d217e1b4..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt +++ /dev/null @@ -1,42 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupCuratedSchema - -sealed interface EditGroupCuratedSchemaUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * The schema being edited, as the group last signed it, or null for a new - * one. - * - * Kept beside the working copy rather than replaced by it, because the - * button proposes only when something differs -- and "differs" needs - * both halves. Null also locks nothing: a new schema's identifier is the - * member's to choose, and an existing one's is not. - */ - val existing: GroupCuratedSchema? = null, - /** - * The relays a new schema starts with: where the group has said it - * publishes, since that is where its list would be read. - * - * Only a new schema is seeded from these. An existing one has its own - * relays signed into it, and those are the ones an edit starts from. - */ - val defaultRelays: List = emptyList(), - /** - * Whether this device holds a share of the group's key. - * - * False leaves the form writable and the button inert, for the same - * reason the other editors do: drafting a schema costs nothing, and only - * a share-holder can open the session that would sign it. - */ - val canSign: Boolean = false, - ): EditGroupCuratedSchemaUIState - - data class Error( - val message: String - ): EditGroupCuratedSchemaUIState - - data object Loading: EditGroupCuratedSchemaUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt deleted file mode 100644 index 77f7ee64..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt +++ /dev/null @@ -1,34 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupNostrProfile - -sealed interface EditGroupNostrProfileUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * What the group has already said about itself, or null the first time. - * - * The form starts from it, and so does the event: an edit is built with - * `MetadataEvent.updateFromPast` so a field this app does not offer survives - * being edited rather than being dropped by it. See - * [GroupNostrProfile.template]. - */ - val groupNostrProfile: GroupNostrProfile? = null, - /** - * Whether this device holds a share of the group's key. - * - * False leaves the form readable and the button inert, which is the honest - * shape: the profile is worth reading either way, and a member without a - * share cannot open a session for one. See - * `ChatRoomDetailUIState.canEditNostrProfile`. - */ - val canSign: Boolean = false, - ): EditGroupNostrProfileUIState - - data class Error( - val message: String - ): EditGroupNostrProfileUIState - - data object Loading: EditGroupNostrProfileUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt deleted file mode 100644 index 08f7963e..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt +++ /dev/null @@ -1,33 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupRelayList - -sealed interface EditGroupRelaysUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * What the group has signed for each set, which is what an edit is measured - * against. - * - * Kept beside the working copy rather than replaced by it, because the - * button only proposes the sets that actually differ -- and "differ" needs - * both halves. See `EditGroupRelaysViewModel.changedSets`. - */ - val signed: List = emptyList(), - /** - * Whether this device holds a share of the group's key. - * - * False leaves the lists readable and the button inert: where the group - * says it lives is worth reading whoever is looking, and proposing a change - * to it is a signature only a share-holder can start. - */ - val canSign: Boolean = false, - ): EditGroupRelaysUIState - - data class Error( - val message: String - ): EditGroupRelaysUIState - - data object Loading: EditGroupRelaysUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt deleted file mode 100644 index 88622196..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedEntriesUIState.kt +++ /dev/null @@ -1,26 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupCuratedEntry - -sealed interface GroupCuratedEntriesUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * The entries this group has accepted into the lists it curates, most - * recently signed first. Empty in a group that has accepted nothing, - * which is every group until a quorum signs an entry. - * - * No capability beside it, because nothing on the screen needs one: - * reading what the group decided takes no share of the key, and - * neither does repeating its signature to a relay. - */ - val groupCuratedEntries: List = emptyList(), - ): GroupCuratedEntriesUIState - - data class Error( - val message: String - ): GroupCuratedEntriesUIState - - data object Loading: GroupCuratedEntriesUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt deleted file mode 100644 index 079c8c33..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupCuratedSchemasUIState.kt +++ /dev/null @@ -1,31 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupCuratedSchema - -sealed interface GroupCuratedSchemasUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * The lists this group curates, as the schemas it signed for them, most - * recently signed first. Empty in a group that curates nothing, which - * is every group until somebody proposes a schema. - */ - val groupCuratedSchemas: List = emptyList(), - /** - * Whether this device holds a share of the group's key. - * - * False leaves the schemas readable -- and their queues, which take no - * share to read -- and hides both ways into the editor, the card's and - * the button's, for the reason `GroupNostrProfileUIState.Loaded.canSign` - * gives. - */ - val canSign: Boolean = false, - ): GroupCuratedSchemasUIState - - data class Error( - val message: String - ): GroupCuratedSchemasUIState - - data object Loading: GroupCuratedSchemasUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt deleted file mode 100644 index 8facb25f..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupNostrProfileUIState.kt +++ /dev/null @@ -1,35 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupNostrProfile - -sealed interface GroupNostrProfileUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * The kind:0 the group signed about itself, or null while it has signed - * none. - * - * Null is an ordinary state and the screen says so -- a group has an - * identity from the moment it has a key and describes it whenever - * somebody gets round to it. - */ - val groupNostrProfile: GroupNostrProfile? = null, - /** - * Whether this device holds a share of the group's key. - * - * False leaves the profile readable and hides the way into the editor: - * proposing one is a signature by the group, and - * `FrostSigningManager.proposeSigningBatch` throws for a device holding - * no share. It is not about permission -- any member with a share may - * propose -- and the quorum is what decides. - */ - val canSign: Boolean = false, - ): GroupNostrProfileUIState - - data class Error( - val message: String - ): GroupNostrProfileUIState - - data object Loading: GroupNostrProfileUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt deleted file mode 100644 index e3d4358b..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupPostsUIState.kt +++ /dev/null @@ -1,40 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost - -sealed interface GroupPostsUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * What the group has said publicly, as itself, newest first. Empty in - * a group that has said nothing, which is every group until somebody - * proposes a post. - */ - val groupPosts: List = emptyList(), - /** - * The group's profile, for the byline on every post. - * - * Read alongside the posts because a post is the one thing here a - * reader might otherwise attribute to whichever member proposed it, - * and the group's own name and picture are what say it was the group. - * Null where the group has not described itself, and the byline falls - * back to the key. - */ - val groupNostrProfile: GroupNostrProfile? = null, - /** - * Whether this device holds a share of the group's key. - * - * False leaves the posts readable and hides the way into the composer, - * for the reason `GroupNostrProfileUIState.Loaded.canSign` gives. - */ - val canSign: Boolean = false, - ): GroupPostsUIState - - data class Error( - val message: String - ): GroupPostsUIState - - data object Loading: GroupPostsUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt deleted file mode 100644 index 48cd1902..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/GroupRelaysUIState.kt +++ /dev/null @@ -1,31 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupRelayList - -sealed interface GroupRelaysUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * Where the group has said it lives on nostr, one entry per relay set. - * - * Empty only in a room that has none of this to say. Every other case - * has four entries, some of them unsigned; see - * `ChatRepository.groupRelayLists`. - */ - val groupRelayLists: List = emptyList(), - /** - * Whether this device holds a share of the group's key. - * - * False leaves the lists readable and hides the way into the editor, - * for the reason `GroupNostrProfileUIState.Loaded.canSign` gives. - */ - val canSign: Boolean = false, - ): GroupRelaysUIState - - data class Error( - val message: String - ): GroupRelaysUIState - - data object Loading: GroupRelaysUIState -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt deleted file mode 100644 index 81a2b502..00000000 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt +++ /dev/null @@ -1,32 +0,0 @@ -package press.mantra.compose.ui.view.state - -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupNostrProfile - -sealed interface ProposeGroupEventUIState { - data class Loaded( - val localChatRoom: LocalChatRoom, - /** - * Who the group says it is, for the byline over the paste. - * - * Whatever is pasted goes out signed by the group and read as the group, - * so the screen shows whose name it will carry -- which is not the - * member pasting it, and not whatever `pubkey` the paste may name. - */ - val groupNostrProfile: GroupNostrProfile? = null, - /** - * Whether this device holds a share of the group's key. - * - * False leaves the paste writable and the button inert, for the same - * reason the typed editors do: reading and checking cost nothing, and - * only a share-holder can open the session that would sign it. - */ - val canSign: Boolean = false, - ): ProposeGroupEventUIState - - data class Error( - val message: String - ): ProposeGroupEventUIState - - data object Loading: ProposeGroupEventUIState -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt deleted file mode 100644 index 3cd150ca..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.GENESIS_AT -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.Profile -import press.mantra.compose.database.model.intermdiate.LocalNostrEvent -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * The queue of a list the group curates, read off whatever this device has - * stored for the coordinate. - * - * The rows come from a filter on kind and `a` tag, which is broader than the - * queue in three ways this reading has to close: an event that does not satisfy - * the schema, a kind 31890 signed by somebody other than the group, and an - * older version of an entry its author has since replaced. And one thing it - * has to add: whether the group has already taken a suggestion up, which is a - * canonical entry sharing its `d`. - * - * Also what to call a stranger. A profile that has arrived is a name; the - * placeholder the indexer writes on first sight is not, and a queue full of - * "LOADING..." would be a queue of nobody. - */ -class CuratedSuggestionTest { - private val chatRoomId = "d4c3b2a1".repeat(8) - private val ada = "a".repeat(64) - private val bob = "b".repeat(64) - - private val films = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField( - "watchUrl", - CuratedFieldType.Url, - isRequired = true, - config = CuratedFieldConfig(tag = "r", marker = "watch") - ), - ), - ).normalized(), - ) - - private val coordinate = films.coordinate() - - @Test - fun `the queue is every suggestion that satisfies the schema, newest first`() { - val queue = CuratedSuggestion.queueOf( - films, - listOf( - stored(suggestion(ada, "older", "The older one", 1_700_000_000)), - stored(suggestion(bob, "newer", "The newer one", 1_700_000_900)), - // No watch link, which the schema requires: not shown with the - // gap blanked out, not shown at all. - stored(suggestion(ada, "broken", "The broken one", 1_700_000_500, watchUrl = null)), - // Another list's reply with this one's identifier. Not this queue. - stored(suggestion(ada, "elsewhere", "Somebody else's", 1_700_000_600, root = "31889:${"e".repeat(64)}:films")), - // A text note that happens to carry the coordinate. - stored(suggestion(ada, "note", "A note", 1_700_000_700).copy(kind = 1)), - ) - ) - - assertEquals(listOf("The newer one", "The older one"), queue.map { it.entry.title }) - assertTrue(queue.none { it.isCurated }) - } - - @Test - fun `an entry its author has replaced shows once, as the newer version`() { - val queue = CuratedSuggestion.queueOf( - films, - listOf( - stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)), - stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin (2014)", 1_700_000_900)), - // The same `d` under another key is another entry: coordinates - // carry the author. - stored(suggestion(bob, "rise", "Bob's rise", 1_700_000_100)), - ) - ) - - assertEquals( - listOf("The Rise and Rise of Bitcoin (2014)", "Bob's rise"), - queue.map { it.entry.title } - ) - } - - @Test - fun `a suggestion the group has taken up is marked, and the group's entry is not in the queue`() { - val queue = CuratedSuggestion.queueOf( - films, - listOf( - stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)), - stored(suggestion(bob, "magic", "Magic Money", 1_700_000_100)), - stored(canonical(chatRoomId, "rise", "The Rise and Rise of Bitcoin", 1_700_000_900)), - // A kind 31890 by somebody who is not the curator is nobody's - // list, and marks nothing. - stored(canonical(bob, "magic", "Magic Money", 1_700_000_950)), - ) - ) - - assertEquals(listOf("Magic Money", "The Rise and Rise of Bitcoin"), queue.map { it.entry.title }) - assertEquals( - mapOf("Magic Money" to false, "The Rise and Rise of Bitcoin" to true), - queue.associate { it.entry.title to it.isCurated } - ) - } - - @Test - fun `a suggester is named by their profile, and by their key until one arrives`() { - val named = Profile( - publicKey = ada, - displayName = "Ada", - nostrEventId = "e".repeat(64), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ) - val placeholder = Profile( - publicKey = bob, - displayName = "LOADING...", - nostrEventId = "e".repeat(64), - createdAt = GENESIS_AT, - ) - - val queue = CuratedSuggestion.queueOf( - films, - listOf( - stored(suggestion(ada, "one", "One", 1_700_000_000), profile = named), - stored(suggestion(bob, "two", "Two", 1_700_000_100), profile = placeholder), - stored(suggestion("c".repeat(64), "three", "Three", 1_700_000_200), profile = null), - ) - ) - - assertEquals( - listOf("cccccccc", "bbbbbbbb", "Ada"), - queue.map { it.suggesterName() } - ) - assertFalse(queue.any { it.suggesterName() == "LOADING..." }) - } - - private fun suggestion( - pubKey: String, - identifier: String, - title: String, - createdAt: Long, - watchUrl: String? = "https://example.com/watch", - root: String = coordinate, - ) = NostrEvent( - id = createdAt.toString().padStart(60, '0') + pubKey.take(4), - pubKey = pubKey, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = listOfNotNull( - arrayOf("d", identifier), - arrayOf("title", title), - watchUrl?.let { arrayOf("r", it, "watch") }, - arrayOf("a", root, "", "root"), - ).toTypedArray(), - content = "", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt), - ) - - private fun canonical(pubKey: String, identifier: String, title: String, createdAt: Long) = - suggestion(pubKey, identifier, title, createdAt).copy(kind = CuratedSchemaEvent.CANONICAL_KIND) - - private fun stored(event: NostrEvent, profile: Profile? = null) = - LocalNostrEvent(nostrEvent = event, profile = profile) -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt deleted file mode 100644 index e65a72db..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt +++ /dev/null @@ -1,317 +0,0 @@ -package press.mantra.compose.nostr - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.crypto.frost.Frost -import fr.acinq.bitcoin.crypto.frost.IndividualNonce -import fr.acinq.bitcoin.crypto.frost.KeyMaterial -import fr.acinq.bitcoin.crypto.frost.SecretNonce -import fr.acinq.bitcoin.crypto.frost.Session -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.extensions.toHex -import press.mantra.compose.managers.SharedKeyDerivation -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSuggestionRef -import press.mantra.compose.nostr.curated.CuratedVisibility -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -/** - * What may be shown as an entry the group accepted into a list it curates. - * - * The reading gate is `GroupCuratedSchemaTest`'s -- the room has to be the - * author and the signature has to be the room's -- plus the two things this - * reader alone decides. An entry is read against the list it replies to, so - * one replying to a list this device holds no schema for is not shown, and one - * the list's own fields refuse is not shown either: it is the check the queue - * makes on a relay's copy, made here on the group's own, so the two screens - * cannot disagree about what the group accepted. - * - * The ordering half is the schema's: one per coordinate, newest winning, since - * re-curating is a newer event under the same `d`. - */ -class GroupCuratedEntryTest { - private val participants = 3 - private val threshold = 2 - - private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") - ), - nParticipants = participants, - threshold = threshold - ) - - /** Another group entirely, for the entries signed by the wrong room. */ - private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") - ), - nParticipants = participants, - threshold = threshold - ) - - private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH - - private val chatRoomId = - SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) - - private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - - private val films = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle, with a link to watch each one.", - visibility = CuratedVisibility.Public, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), - CuratedField( - name = "watchUrl", - type = CuratedFieldType.Url, - isRequired = true, - label = "Where to watch", - config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true), - ), - ), - relays = listOf("wss://relay.example.com"), - ) - - private val books = films.copy(identifier = "books", name = "Books worth translating") - - /** The lists as the group's screen holds them: read off the group's real signatures. */ - private val schemas: List = listOf(films, books).map { schema -> - assertNotNull(GroupCuratedSchema.of(signed(CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000)), chatRoomId)) - } - - private val filmsList = schemas.first { it.identifier == "films" } - private val booksList = schemas.first { it.identifier == "books" } - - private fun riseAndRise(year: String = "2014") = mapOf( - "identifier" to listOf("imdb:tt2821314"), - "title" to listOf("The Rise and Rise of Bitcoin"), - "year" to listOf(year), - "watchUrl" to listOf("https://bitcoindoc.com/"), - ) - - private val source = CuratedSuggestionRef( - id = "a".repeat(64), - coordinate = "31888:$suggester:imdb:tt2821314", - pubKey = suggester, - ) - - private fun entry( - list: GroupCuratedSchema = filmsList, - values: Map> = riseAndRise(), - createdAt: Long = 1_700_000_900, - ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate( - schema = list.schema, - coordinate = list.coordinate(), - curator = list.publicKey, - values = values, - source = source, - createdAt = createdAt, - ) - - @Test - fun `an entry the room signed into a list it curates is one it accepted`() { - val accepted = assertNotNull(GroupCuratedEntry.of(signed(entry()), chatRoomId, schemas)) - - assertEquals("The Rise and Rise of Bitcoin", accepted.title) - assertEquals("imdb:tt2821314", accepted.identifier) - assertEquals("31890:$chatRoomId:imdb:tt2821314", accepted.coordinate()) - assertEquals(filmsList, accepted.list) - assertEquals("2014", accepted.entry.value("year")) - assertEquals(source, accepted.entry.source) - } - - @Test - fun `an entry another group signed is not this group's`() { - val stranger = GroupSignedEvent.fromEvent( - event = event(entry(), material = strangerMaterial), - chatRoomId = chatRoomId - ) - - assertNull(GroupCuratedEntry.of(stranger, chatRoomId, schemas)) - } - - @Test - fun `an entry the room did not sign is not one`() { - // Authored by the room and signed by somebody else: a member declaring - // the group accepted something, which the reading verifies rather than - // trusting the author field. - assertNull(GroupCuratedEntry.of(signed(entry(), signer = strangerMaterial), chatRoomId, schemas)) - - listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> - assertNull( - GroupCuratedEntry.of(signed(entry(), signature = rubbish), chatRoomId, schemas), - "an entry signed with \"$rubbish\" was accepted" - ) - } - } - - @Test - fun `an event of another kind is not an entry`() { - assertNull( - GroupCuratedEntry.of(signed(entry()).copy(kind = CuratedEntryEvent.SUGGESTION_KIND), chatRoomId, schemas) - ) - } - - @Test - fun `an entry into a list this device holds no schema for is not shown`() { - // Really signed, and replying to a real list -- just not one among the - // schemas the reader was given. There are no fields to read it by. - assertNull(GroupCuratedEntry.of(signed(entry(list = booksList)), chatRoomId, schemas = listOf(filmsList))) - } - - @Test - fun `an entry the list's own fields refuse is not shown`() { - // The room's real signature over a year outside the schema's range: the - // group's statement, and still not an entry the list's readers would - // count. The queue would drop the relay's copy for the same reason. - val outOfRange = signed(entry(values = riseAndRise(year = "1850"))) - - assertNull(GroupCuratedEntry.of(outOfRange, chatRoomId, schemas)) - assertEquals(emptyList(), GroupCuratedEntry.newestPerEntryAmong(listOf(outOfRange), chatRoomId, schemas)) - } - - @Test - fun `the newest per coordinate wins, and two identifiers are two entries`() { - val first = signed(entry(createdAt = 1_700_000_900)) - val revised = signed(entry(values = riseAndRise(year = "2015"), createdAt = 1_700_001_800)) - val another = signed( - entry( - values = mapOf( - "identifier" to listOf("magic-money"), - "title" to listOf("Magic Money"), - "watchUrl" to listOf("https://example.com/watch"), - ), - createdAt = 1_700_001_200, - ) - ) - - listOf( - listOf(first, revised, another), - listOf(another, revised, first), - listOf(revised, another, first), - ).forEach { events -> - val accepted = GroupCuratedEntry.newestPerEntryAmong(events, chatRoomId, schemas) - - assertEquals( - listOf("imdb:tt2821314", "magic-money"), - accepted.map { it.identifier }, - "the entries came back in the wrong order, or one of them went missing" - ) - assertEquals("2015", accepted.first().entry.value("year"), "a re-curation did not replace the entry it revised") - } - } - - @Test - fun `the same identifier in two lists is one coordinate, and the newer decides`() { - val inFilms = signed(entry(list = filmsList, createdAt = 1_700_000_900)) - val inBooks = signed(entry(list = booksList, createdAt = 1_700_001_800)) - - val accepted = GroupCuratedEntry.newestPerEntryAmong(listOf(inFilms, inBooks), chatRoomId, schemas) - - assertEquals(1, accepted.size, "one coordinate was shown as two entries") - assertEquals(booksList, accepted.single().list) - } - - /** An entry as `FrostSigningManager.complete` files one. */ - private fun signed( - template: EventTemplate<*>, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): GroupSignedEvent = GroupSignedEvent.fromEvent( - event = event(template, material, signer, signature), - chatRoomId = chatRoomId, - derivationPath = SharedKeyDerivation.formatPath(path) - ) - - private fun event( - template: EventTemplate<*>, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): Event { - val groupPubKey = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .hex - - val id = EventHasher.hashId( - pubKey = groupPubKey, - createdAt = template.createdAt, - kind = template.kind, - tags = template.tags, - content = template.content - ) - - return Event( - id = id, - pubKey = groupPubKey, - createdAt = template.createdAt, - kind = template.kind, - tags = template.tags, - content = template.content, - sig = signature ?: groupSignature(signer, id) - ) - } - - /** - * A real FROST signature by [material]'s quorum over [eventId], through the - * same shape `FrostSigningManager.advance` runs. - */ - private fun groupSignature(material: KeyMaterial, eventId: String): String { - val cache = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .cache - val message = ByteVector(eventId.hexToByteArray()) - val signerIds = listOf(0, 1) - - val nonces = signerIds.map { signerId -> - SecretNonce.generate( - sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), - secretShare = material.secretShares[signerId], - publicShare = material.publicShares[signerId], - tweakedThresholdPublicKey = cache.tweakedPublicKey, - message = message, - extraInput = null - ) - } - - val signingSession = Session.create( - aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = signerIds.map { material.publicShares[it] }, - nParticipants = participants, - threshold = threshold, - tweakCache = cache, - message = message - ) - - val partials = signerIds.mapIndexed { position, signerId -> - signingSession.sign( - nonces[position].first, - material.secretShares[signerId], - signerId.toUInt() - ).right!! - } - - return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt deleted file mode 100644 index 84751493..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt +++ /dev/null @@ -1,317 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.extensions.toHex -import press.mantra.compose.managers.SharedKeyDerivation -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.crypto.frost.Frost -import fr.acinq.bitcoin.crypto.frost.IndividualNonce -import fr.acinq.bitcoin.crypto.frost.KeyMaterial -import fr.acinq.bitcoin.crypto.frost.SecretNonce -import fr.acinq.bitcoin.crypto.frost.Session -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * What may be shown as a list the group curates. - * - * The reading gate is `GroupPostTest`'s -- the room has to be the author and the - * signature has to be the room's -- and it carries a weight of its own here: a - * schema read wrong would have strangers filling in a form under a group's name - * that the group never agreed to, and the group's key accepting entries against - * it. The third refusal is this reader's alone: a schema the room really signed - * but that no client could act on is not shown as one it curates. - * - * The ordering half is between the profile's and the posts': one per identifier, - * newest winning within it, because the kind is addressable and a group may - * curate several lists. - */ -class GroupCuratedSchemaTest { - private val participants = 3 - private val threshold = 2 - - private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") - ), - nParticipants = participants, - threshold = threshold - ) - - /** Another group entirely, for the schemas signed by the wrong room. */ - private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") - ), - nParticipants = participants, - threshold = threshold - ) - - private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH - - private val chatRoomId = - SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) - - private val films = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle, with a link to watch each one.", - visibility = CuratedVisibility.Public, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField( - name = "watchUrl", - type = CuratedFieldType.Url, - isRequired = true, - placeholder = "https://…", - label = "Where to watch", - config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true), - ), - ), - relays = listOf("wss://relay.example.com"), - ) - - @Test - fun `a schema the room signed is a list the group curates`() { - val curated = assertNotNull( - GroupCuratedSchema.of(signedEvent = signed(films), chatRoomId = chatRoomId) - ) - - assertEquals("films", curated.identifier) - assertEquals(chatRoomId, curated.publicKey) - assertEquals("31889:$chatRoomId:films", curated.coordinate()) - assertEquals(films, curated.schema) - assertEquals("Films worth translating", curated.schema.displayName()) - } - - @Test - fun `a schema another group signed is not this group's`() { - // A real quorum and a real signature by a group with no standing to - // curate for this one. The row names this room because that is where it - // was filed; the author is what decides whose list it is. - val stranger = GroupSignedEvent.fromEvent( - event = schemaEvent(films, material = strangerMaterial), - chatRoomId = chatRoomId - ) - - assertNull(GroupCuratedSchema.of(signedEvent = stranger, chatRoomId = chatRoomId)) - } - - @Test - fun `a schema the room did not sign is not one`() { - // Authored by the room and signed by somebody else: the shape a member - // declaring a list in the group's name would produce, and the reason the - // reading verifies rather than trusting the author field. - assertNull( - GroupCuratedSchema.of( - signedEvent = signed(films, signer = strangerMaterial), - chatRoomId = chatRoomId - ) - ) - - listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> - assertNull( - GroupCuratedSchema.of( - signedEvent = signed(films, signature = rubbish), - chatRoomId = chatRoomId - ), - "a schema signed with \"$rubbish\" was accepted" - ) - } - } - - @Test - fun `an event of another kind is not a schema`() { - assertNull( - GroupCuratedSchema.of( - signedEvent = signed(films).copy(kind = CuratedSchemaEvent.SUGGESTION_KIND), - chatRoomId = chatRoomId - ) - ) - } - - @Test - fun `a schema the room signed but no client could act on is refused`() { - // The room's real signature over a kind 31889 with no visibility. It is - // the group's statement and it is still not a list: a reader that showed - // it would be showing a form nobody may be allowed to fill in. - val unusable = signed( - films, - tags = CuratedSchemaEvent.template(films, createdAt = 1_700_000_000).tags - .filterNot { it[0] == "visibility" } - .toTypedArray() - ) - - assertNull(GroupCuratedSchema.of(signedEvent = unusable, chatRoomId = chatRoomId)) - assertEquals(emptyList(), GroupCuratedSchema.newestPerListAmong(listOf(unusable), chatRoomId)) - } - - @Test - fun `the newest per identifier wins, and two identifiers are two lists`() { - val firstFilms = signed(films, createdAt = 1_700_000_000) - val editedFilms = signed(films.copy(description = "Films the group would subtitle."), createdAt = 1_700_000_900) - val books = signed(films.copy(identifier = "books", name = "Books worth translating"), createdAt = 1_700_000_300) - - listOf( - listOf(firstFilms, editedFilms, books), - listOf(books, editedFilms, firstFilms), - listOf(editedFilms, books, firstFilms), - ).forEach { events -> - val lists = GroupCuratedSchema.newestPerListAmong(events, chatRoomId) - - assertEquals( - listOf("films", "books"), - lists.map { it.identifier }, - "the lists came back in the wrong order, or one of them went missing" - ) - assertEquals( - "Films the group would subtitle.", - lists.first().schema.description, - "an edit did not replace the schema it revised" - ) - } - } - - @Test - fun `two versions signed in the same second resolve the same way on every device`() { - val one = signed(films.copy(description = "One."), createdAt = 1_700_000_000) - val other = signed(films.copy(description = "Other."), createdAt = 1_700_000_000) - - val expected = GroupCuratedSchema.newestPerListAmong(listOf(one, other), chatRoomId).single() - - assertEquals( - expected, - GroupCuratedSchema.newestPerListAmong(listOf(other, one), chatRoomId).single(), - "a tie on the timestamp was broken differently by the two orderings" - ) - assertEquals( - if (one.id > other.id) "One." else "Other.", - expected.schema.description, - "the tie should break on the id, which is the only thing both devices share" - ) - } - - @Test - fun `a schema the editor built reads back as the group defined it`() { - val template = GroupCuratedSchema.template(films) - - assertEquals(CuratedSchemaEvent.KIND, template.kind) - assertEquals(films.description, template.content) - - val curated = assertNotNull( - GroupCuratedSchema.of( - signedEvent = signed(films, tags = template.tags, content = template.content), - chatRoomId = chatRoomId - ) - ) - - assertEquals(films, curated.schema) - assertTrue(curated.schema.fields.first { it.name == "watchUrl" }.isRequired) - } - - /** A schema as `FrostSigningManager.complete` files one. */ - private fun signed( - schema: CuratedSchema, - tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, - content: String = schema.description, - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): GroupSignedEvent = GroupSignedEvent.fromEvent( - event = schemaEvent(schema, tags, content, createdAt, material, signer, signature), - chatRoomId = chatRoomId, - derivationPath = SharedKeyDerivation.formatPath(path) - ) - - private fun schemaEvent( - schema: CuratedSchema, - tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, - content: String = schema.description, - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): Event { - val groupPubKey = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .hex - - val id = EventHasher.hashId( - pubKey = groupPubKey, - createdAt = createdAt, - kind = CuratedSchemaEvent.KIND, - tags = tags, - content = content - ) - - return Event( - id = id, - pubKey = groupPubKey, - createdAt = createdAt, - kind = CuratedSchemaEvent.KIND, - tags = tags, - content = content, - sig = signature ?: groupSignature(signer, id) - ) - } - - /** - * A real FROST signature by [material]'s quorum over [eventId], through the - * same shape `FrostSigningManager.advance` runs. - */ - private fun groupSignature(material: KeyMaterial, eventId: String): String { - val cache = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .cache - val message = ByteVector(eventId.hexToByteArray()) - val signerIds = listOf(0, 1) - - val nonces = signerIds.map { signerId -> - SecretNonce.generate( - sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), - secretShare = material.secretShares[signerId], - publicShare = material.publicShares[signerId], - tweakedThresholdPublicKey = cache.tweakedPublicKey, - message = message, - extraInput = null - ) - } - - val signingSession = Session.create( - aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = signerIds.map { material.publicShares[it] }, - nParticipants = participants, - threshold = threshold, - tweakCache = cache, - message = message - ) - - val partials = signerIds.mapIndexed { position, signerId -> - signingSession.sign( - nonces[position].first, - material.secretShares[signerId], - signerId.toUInt() - ).right!! - } - - return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt deleted file mode 100644 index a9beafd9..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt +++ /dev/null @@ -1,338 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.extensions.toHex -import press.mantra.compose.managers.SharedKeyDerivation -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSchemaProblem -import press.mantra.compose.nostr.curated.CuratedVisibility -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.crypto.frost.Frost -import fr.acinq.bitcoin.crypto.frost.IndividualNonce -import fr.acinq.bitcoin.crypto.frost.KeyMaterial -import fr.acinq.bitcoin.crypto.frost.SecretNonce -import fr.acinq.bitcoin.crypto.frost.Session -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * What a pasted event may become, and where it goes once the group has signed it. - * - * Two halves. The first is the reading: which pastes are refused and why, and - * that what is refused is exactly what a reader would refuse -- the point of - * checking before proposing is that a quorum is never asked to sign something - * that will be filed and shown nowhere. - * - * The second is the promise the feature makes: a pasted kind 0 becomes the - * profile, a kind 1 a post, a kind 31889 a schema, a relay list a relay list. That - * is not this module's doing -- `FrostSigningManager.complete` files every signed - * event and the group's screen reads each section off those by kind -- so the - * test is the whole chain: the paste read here, signed by a real quorum through - * the same shape `FrostSigningManager.advance` runs, and read back by the reader - * that section uses. - */ -class GroupEventProposalTest { - private val participants = 3 - private val threshold = 2 - - private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") - ), - nParticipants = participants, - threshold = threshold - ) - - private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH - - private val chatRoomId = - SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) - - /** - * The bitcoin.mov schema as `npm run schema:dry` would print it: a whole - * event, id, author, signature and time included, abbreviated to the tags - * a reading depends on. - */ - private val pastedSchema = """ - { - "id": "b2f8c7a7b4d5e6f70123456789abcdef0123456789abcdef0123456789abcdef", - "pubkey": "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25", - "created_at": 1735689600, - "kind": 31889, - "tags": [ - ["d", "bitcoin.mov"], - ["title", "bitcoin.mov suggestion"], - ["name", "bitcoin.mov"], - ["description", "Fields for a bitcoin.mov entry."], - ["k", "31888"], - ["visibility", "public"], - ["domain", "bitcoin.mov"], - ["relay", "wss://ephemeral.mantra.press"], - ["field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"], - ["field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"], - ["field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"repeat\":true}"] - ], - "content": "Fields for a bitcoin.mov entry.", - "sig": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00" - } - """.trimIndent() - - @Test - fun `a pasted event is read as its kind, tags and content, and nothing else`() { - val ready = assertIs(GroupEventProposal.read(pastedSchema)) - - assertEquals(31889, ready.kind) - assertEquals("Fields for a bitcoin.mov entry.", ready.content) - assertEquals(11, ready.tags.size) - assertEquals(listOf("d", "bitcoin.mov"), ready.tags.first().toList()) - // The config blob comes through verbatim, escapes and all. - assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", ready.tags[8][6]) - - // The id, author, signature and time are not the paste's to decide, and - // there is nowhere on a reading for them to go: the session hashes the id - // over the room's key and the time it opens at. - val preview = ready.preview() - assertEquals("", preview.pubKey) - assertEquals(0L, preview.createdAt) - } - - @Test - fun `a bare template with no tags or content still reads`() { - val ready = assertIs( - GroupEventProposal.read("""{"kind": 0, "content": "{}"}""") - ) - assertEquals(0, ready.kind) - assertEquals(0, ready.tags.size) - - // A numeric string for the kind is what a hand types; a relay writes a number. - assertIs( - GroupEventProposal.read("""{"kind": "1", "content": "Hello"}""") - ) - } - - @Test - fun `what is not an event is refused and named`() { - assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("not json at all")) - assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("{\"kind\": 1,")) - assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("[1, 2, 3]")) - assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("\"a string\"")) - assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"content": "Hello"}""")) - assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"kind": "one"}""")) - assertEquals( - GroupEventProposal.Refused.TagsMalformed, - GroupEventProposal.read("""{"kind": 1, "tags": "e", "content": "Hello"}""") - ) - assertEquals( - GroupEventProposal.Refused.TagsMalformed, - GroupEventProposal.read("""{"kind": 1, "tags": [["e", 1]], "content": "Hello"}""") - ) - assertEquals( - GroupEventProposal.Refused.ContentNotText, - GroupEventProposal.read("""{"kind": 1, "content": {"note": "Hello"}}""") - ) - } - - @Test - fun `a kind the group's screen has no place for is refused, whatever else it says`() { - // A long-form article is a perfectly good nostr event and the group could - // sign it. It would then be filed and shown nowhere, with nothing to tell - // the member their quorum was spent on it. - assertEquals( - GroupEventProposal.Refused.KindNotAccepted(30023), - GroupEventProposal.read("""{"kind": 30023, "tags": [["d", "x"]], "content": "An article"}""") - ) - - // The kinds with a section: the profile, the posts, the four relay lists - // and the schemas. A subgroup certificate is deliberately not among them. - assertEquals( - setOf(0, 1, 10002, 10050, 10007, 10006, 31889), - GroupEventProposal.ACCEPTED_KINDS - ) - } - - @Test - fun `a kind zero whose content is not a profile is refused, and an empty one is not`() { - assertEquals( - GroupEventProposal.Refused.ProfileUnreadable, - GroupEventProposal.read("""{"kind": 0, "content": "not a profile"}""") - ) - - // Wiping a profile is a thing groups are entitled to do -- the same line - // `GroupNostrProfile.of` draws. - assertIs(GroupEventProposal.read("""{"kind": 0, "content": ""}""")) - assertIs( - GroupEventProposal.read("""{"kind": 0, "content": "{\"name\":\"Translation collective\"}"}""") - ) - } - - @Test - fun `a blank post is refused, the way the composer refuses one`() { - listOf("", " ", "\\n\\n").forEach { blank -> - assertEquals( - GroupEventProposal.Refused.PostBlank, - GroupEventProposal.read("""{"kind": 1, "content": "$blank"}"""), - "a post saying \"$blank\" was accepted" - ) - } - } - - @Test - fun `a schema a reader would refuse is refused here, with the reader's reasons`() { - val withoutVisibility = pastedSchema.replace("""["visibility", "public"],""", "") - - assertEquals( - GroupEventProposal.Refused.SchemaUnusable(listOf(CuratedSchemaProblem.VisibilityMissing)), - GroupEventProposal.read(withoutVisibility) - ) - } - - @Test - fun `a relay list reads whether or not it names a relay`() { - // An empty one is a list the group is withdrawing, which the relays card - // shows as "no relays" rather than as nothing. - assertIs(GroupEventProposal.read("""{"kind": 10002}""")) - assertIs( - GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""") - ) - } - - @Test - fun `a pasted kind zero, once signed, is the group's profile`() { - val ready = assertIs( - GroupEventProposal.read( - """{"kind": 0, "pubkey": "not the group", "content": "{\"name\":\"Translation collective\",\"about\":\"We translate hard books.\"}"}""" - ) - ) - - val profile = assertNotNull(GroupNostrProfile.of(signed(ready), chatRoomId)) - - assertEquals("Translation collective", profile.name()) - assertEquals("We translate hard books.", profile.about()) - assertEquals(chatRoomId, profile.publicKey) - } - - @Test - fun `a pasted kind one, once signed, is one of the group's posts`() { - val ready = assertIs( - GroupEventProposal.read("""{"kind": 1, "tags": [], "content": "We have finished the first chapter."}""") - ) - - val posts = GroupPost.newestFirstAmong(listOf(signed(ready)), chatRoomId) - - assertEquals(listOf("We have finished the first chapter."), posts.map { it.content }) - assertTrue(posts.single().isNewThread()) - } - - @Test - fun `a pasted schema, once signed, is a list the group curates`() { - val ready = assertIs(GroupEventProposal.read(pastedSchema)) - - val curated = assertNotNull(GroupCuratedSchema.of(signed(ready), chatRoomId)) - - assertEquals("bitcoin.mov", curated.identifier) - assertEquals("bitcoin.mov", curated.schema.displayName()) - assertEquals(CuratedVisibility.Public, curated.schema.visibility) - assertEquals(listOf("identifier", "title", "watchUrl"), curated.schema.fields.map { it.name }) - // The coordinate is the group's, not the pasted pubkey's: the group is - // the curator of what it signed. - assertEquals("${CuratedSchemaEvent.KIND}:$chatRoomId:bitcoin.mov", curated.coordinate()) - } - - @Test - fun `a pasted relay list, once signed, is one of the group's relay lists`() { - val ready = assertIs( - GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""") - ) - - val list = GroupRelayList.newestAmong(listOf(signed(ready)), chatRoomId, GroupRelaySet.General) - - assertTrue(list.isAgreed) - assertEquals(listOf("relay.example.com"), list.relays.map { it.displayUrl() }) - assertEquals(listOf(false), list.relays.map { it.read }) - assertEquals(listOf(true), list.relays.map { it.write }) - } - - /** - * A reading as `FrostSigningManager.complete` would file it: authored by the - * room's key at its path, the id hashed over that, a real quorum's signature - * on it, and the time the session opened at rather than anything pasted. - */ - private fun signed(ready: GroupEventProposal.Ready, createdAt: Long = 1_700_000_000): GroupSignedEvent { - val groupPubKey = SharedKeyDerivation - .derive(groupMaterial.thresholdPublicKey.value.toHex(), path) - .hex - - val id = EventHasher.hashId( - pubKey = groupPubKey, - createdAt = createdAt, - kind = ready.kind, - tags = ready.tags, - content = ready.content - ) - - return GroupSignedEvent.fromEvent( - event = Event( - id = id, - pubKey = groupPubKey, - createdAt = createdAt, - kind = ready.kind, - tags = ready.tags, - content = ready.content, - sig = groupSignature(groupMaterial, id) - ), - chatRoomId = chatRoomId, - derivationPath = SharedKeyDerivation.formatPath(path) - ) - } - - /** - * A real FROST signature by [material]'s quorum over [eventId], through the - * same shape `FrostSigningManager.advance` runs. - */ - private fun groupSignature(material: KeyMaterial, eventId: String): String { - val cache = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .cache - val message = ByteVector(eventId.hexToByteArray()) - val signerIds = listOf(0, 1) - - val nonces = signerIds.map { signerId -> - SecretNonce.generate( - sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), - secretShare = material.secretShares[signerId], - publicShare = material.publicShares[signerId], - tweakedThresholdPublicKey = cache.tweakedPublicKey, - message = message, - extraInput = null - ) - } - - val signingSession = Session.create( - aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = signerIds.map { material.publicShares[it] }, - nParticipants = participants, - threshold = threshold, - tweakCache = cache, - message = message - ) - - val partials = signerIds.mapIndexed { position, signerId -> - signingSession.sign( - nonces[position].first, - material.secretShares[signerId], - signerId.toUInt() - ).right!! - } - - return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt deleted file mode 100644 index 944e7199..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt +++ /dev/null @@ -1,438 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.extensions.toHex -import press.mantra.compose.managers.SharedKeyDerivation -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.crypto.frost.Frost -import fr.acinq.bitcoin.crypto.frost.IndividualNonce -import fr.acinq.bitcoin.crypto.frost.KeyMaterial -import fr.acinq.bitcoin.crypto.frost.SecretNonce -import fr.acinq.bitcoin.crypto.frost.Session -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -/** - * What a group's nostr profile is allowed to be read out of. - * - * A room's id is the pubkey it signs as, so a kind:0 authored by that key is the - * group describing itself and a kind:0 authored by anything else is not -- however - * it came to be filed against the room. The reading is what the group detail screen - * shows and what an edit is built on top of, so both halves are worth pinning: - * which events become a profile, and what an edit does to the one before it. - * - * The edit half is the one with a silent failure behind it. `updateFromPast` is - * used precisely so a field this app does not offer survives being edited, and - * `createNew` in its place would compile, pass any test that only looked at the - * fields the form knows about, and quietly wipe the rest of the group's profile - * every time somebody fixed a typo in its name. - */ -class GroupNostrProfileTest { - private val participants = 3 - private val threshold = 2 - - /** The group whose profile this is. */ - private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") - ), - nParticipants = participants, - threshold = threshold - ) - - /** Another group entirely, for the profiles signed by the wrong room. */ - private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") - ), - nParticipants = participants, - threshold = threshold - ) - - private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH - - private val chatRoomId = - SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) - private val strangerChatRoomId = - SharedKeyDerivation.marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path) - - @Test - fun `a kind zero the room signed is the group's profile`() { - val profile = GroupNostrProfile.of( - signedEvent = signed( - content = """{"name":"Translation collective","about":"We translate hard books.",""" + - """"picture":"https://example.com/group.png","nip05":"group@example.com"}""" - ), - chatRoomId = chatRoomId - ) - - assertNotNull(profile) - assertEquals("Translation collective", profile.name()) - assertEquals("We translate hard books.", profile.about()) - assertEquals("https://example.com/group.png", profile.picture()) - assertEquals("group@example.com", profile.nip05()) - assertEquals(chatRoomId, profile.publicKey) - } - - @Test - fun `display_name is what a name is read out of when both are set`() { - // Every other nostr client prefers it, and `template` writes both so they - // agree. A group whose two names disagree got them from somewhere else, - // and the answer has to be the one the rest of nostr will give. - val profile = GroupNostrProfile.of( - signedEvent = signed( - content = """{"name":"collective","display_name":"Translation collective"}""" - ), - chatRoomId = chatRoomId - ) - - assertEquals("Translation collective", profile?.name()) - } - - @Test - fun `a kind zero another group signed is not this group's profile`() { - // A real quorum and a real signature by a group with no standing to say - // anything about this room. The row names this room because that is where - // the event was filed; the author is what decides whose profile it is. - val stranger = GroupSignedEvent.fromEvent( - event = metadataEvent( - content = """{"name":"Somebody else"}""", - material = strangerMaterial - ), - chatRoomId = chatRoomId - ) - - assertEquals(strangerChatRoomId, stranger.publicKey, "the fixture signs as the stranger") - assertNull(GroupNostrProfile.of(signedEvent = stranger, chatRoomId = chatRoomId)) - } - - @Test - fun `a kind zero whose signature is not the room's is not a profile`() { - // Authored by the room, saying the right things, and signed by a key that - // is not the room's. This is the shape a member forging a rename produces. - assertNull( - GroupNostrProfile.of( - signedEvent = signed( - content = """{"name":"Renamed by one member"}""", - signer = strangerMaterial - ), - chatRoomId = chatRoomId - ) - ) - - // And everything off the wire is allowed to be nonsense, which means no - // rather than an exception. - listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> - assertNull( - GroupNostrProfile.of( - signedEvent = signed(content = """{"name":"x"}""", signature = rubbish), - chatRoomId = chatRoomId - ), - "a profile signed with \"$rubbish\" was accepted" - ) - } - } - - @Test - fun `an event of another kind is not a profile`() { - assertNull( - GroupNostrProfile.of( - signedEvent = signed(content = """{"name":"x"}""").copy(kind = 1), - chatRoomId = chatRoomId - ) - ) - } - - @Test - fun `the newest profile the group signed is the one that counts`() { - // kind:0 is replaceable: editing a profile signs a second one, and the - // second is the answer whatever order the query hands them over in. - val older = signed(content = """{"name":"What it was called"}""", createdAt = 1_700_000_000) - val newer = signed(content = """{"name":"What it is called"}""", createdAt = 1_700_000_900) - - listOf(listOf(older, newer), listOf(newer, older)).forEach { events -> - assertEquals( - "What it is called", - GroupNostrProfile.newestAmong(events, chatRoomId)?.name(), - "the newest profile lost to query order" - ) - } - } - - @Test - fun `two profiles signed in the same second resolve the same way on every device`() { - // Two devices catching up read the same events in whatever order the - // relay or the query gives them, and they have to end up showing the same - // profile. A tie on the timestamp is broken by the id, which every device - // agrees on because it is a hash of the event. - val one = signed(content = """{"name":"One"}""", createdAt = 1_700_000_000) - val other = signed(content = """{"name":"Other"}""", createdAt = 1_700_000_000) - - val expected = GroupNostrProfile.newestAmong(listOf(one, other), chatRoomId)?.name() - - assertNotNull(expected) - assertEquals( - expected, - GroupNostrProfile.newestAmong(listOf(other, one), chatRoomId)?.name(), - "a tie on the timestamp was broken differently by the two orderings" - ) - assertEquals( - if (one.id > other.id) "One" else "Other", - expected, - "the tie should break on the id, which is the only thing both devices share" - ) - } - - @Test - fun `the first profile a group signs carries the fields it was given`() { - val template = GroupNostrProfile.template( - latest = null, - name = "Translation collective", - about = "We translate hard books.", - picture = "https://example.com/group.png", - website = "https://example.com", - nip05 = "group@example.com", - lud16 = "group@getalby.com" - ) - - val profile = GroupNostrProfile.of( - signedEvent = signed(content = template.content), - chatRoomId = chatRoomId - ) - - assertNotNull(profile) - assertEquals(MetadataEvent.KIND, template.kind) - assertEquals("Translation collective", profile.name()) - assertEquals("We translate hard books.", profile.about()) - assertEquals("https://example.com/group.png", profile.picture()) - assertEquals("https://example.com", profile.website()) - assertEquals("group@example.com", profile.nip05()) - assertEquals("group@getalby.com", profile.lud16()) - } - - @Test - fun `an edit keeps a field the form does not offer`() { - // The reason `template` builds on the group's own event rather than from - // scratch. `bot` is not on the form and never will be reached by it; an - // edit that dropped it would be this app deciding a group is not a bot - // because somebody fixed its name. - val existing = GroupNostrProfile.of( - signedEvent = signed(content = """{"name":"Old name","bot":true}"""), - chatRoomId = chatRoomId - ) - assertNotNull(existing) - - val template = GroupNostrProfile.template( - latest = existing, - name = "New name", - about = "", - picture = "", - website = "", - nip05 = "", - lud16 = "" - ) - - val edited = GroupNostrProfile.of( - signedEvent = signed(content = template.content), - chatRoomId = chatRoomId - ) - - assertNotNull(edited) - assertEquals("New name", edited.name()) - assertEquals(true, edited.metadata.bot, "an edit dropped a field the form does not offer") - } - - @Test - fun `an emptied field unsays what the group had said`() { - // The only way to withdraw something a group has signed. A form that sent - // nulls for its blanks could add to a profile and never subtract from one. - val existing = GroupNostrProfile.of( - signedEvent = signed( - content = """{"name":"Translation collective","about":"Something regretted"}""" - ), - chatRoomId = chatRoomId - ) - assertNotNull(existing) - - val template = GroupNostrProfile.template( - latest = existing, - name = "Translation collective", - about = "", - picture = "", - website = "", - nip05 = "", - lud16 = "" - ) - - val edited = GroupNostrProfile.of( - signedEvent = signed(content = template.content), - chatRoomId = chatRoomId - ) - - assertNotNull(edited) - assertNull(edited.about()) - assertEquals("Translation collective", edited.name()) - } - - @Test - fun `a name is written to both of the keys a reader might look in`() { - val template = GroupNostrProfile.template( - latest = null, - name = "Translation collective", - about = "", - picture = "", - website = "", - nip05 = "", - lud16 = "" - ) - - val metadata = GroupNostrProfile.of( - signedEvent = signed(content = template.content), - chatRoomId = chatRoomId - )?.metadata - - assertNotNull(metadata) - assertEquals("Translation collective", metadata.name) - assertEquals( - "Translation collective", - metadata.displayName, - "a client preferring display_name would show the old name after an edit" - ) - } - - @Test - fun `an empty profile is a profile rather than a parse failure`() { - // Wiping a profile is something a group is entitled to do, and an empty - // kind:0 is how nostr says it. Reading it as nothing would leave the - // screen claiming the group had never said anything. - val profile = GroupNostrProfile.of( - signedEvent = signed(content = ""), - chatRoomId = chatRoomId - ) - - assertNotNull(profile) - assertNull(profile.name()) - assertNull(profile.about()) - } - - @Test - fun `content that is not a profile at all is refused`() { - assertNull( - GroupNostrProfile.of( - signedEvent = signed(content = "not json"), - chatRoomId = chatRoomId - ) - ) - } - - /** - * A profile row as `FrostSigningManager.complete` files one: the content under - * the room's own key, with the id hashed over it and the group's signature on - * it. - * - * [signer] is pulled apart from the author so a signature by the wrong quorum - * can be tested, and [signature] replaces the real one outright for what is - * not a signature at all. - */ - private fun signed( - content: String, - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): GroupSignedEvent = GroupSignedEvent.fromEvent( - event = metadataEvent( - content = content, - createdAt = createdAt, - material = material, - signer = signer, - signature = signature - ), - chatRoomId = chatRoomId, - derivationPath = SharedKeyDerivation.formatPath(path) - ) - - private fun metadataEvent( - content: String, - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): Event { - val groupPubKey = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .hex - - val id = EventHasher.hashId( - pubKey = groupPubKey, - createdAt = createdAt, - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = content - ) - - return Event( - id = id, - pubKey = groupPubKey, - createdAt = createdAt, - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = content, - sig = signature ?: groupSignature(signer, id) - ) - } - - /** - * A real FROST signature by [material]'s quorum over [eventId], through the - * same shape `FrostSigningManager.advance` runs. - * - * Assembled any other way it would not be evidence about the signatures this - * app actually produces. - */ - private fun groupSignature(material: KeyMaterial, eventId: String): String { - val cache = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .cache - val message = ByteVector(eventId.hexToByteArray()) - val signerIds = listOf(0, 1) - - val nonces = signerIds.map { signerId -> - SecretNonce.generate( - sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), - secretShare = material.secretShares[signerId], - publicShare = material.publicShares[signerId], - tweakedThresholdPublicKey = cache.tweakedPublicKey, - message = message, - extraInput = null - ) - } - - val signingSession = Session.create( - aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = signerIds.map { material.publicShares[it] }, - nParticipants = participants, - threshold = threshold, - tweakCache = cache, - message = message - ) - - val partials = signerIds.mapIndexed { position, signerId -> - signingSession.sign( - nonces[position].first, - material.secretShares[signerId], - signerId.toUInt() - ).right!! - } - - return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt deleted file mode 100644 index 53fc1c73..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt +++ /dev/null @@ -1,307 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.extensions.toHex -import press.mantra.compose.managers.SharedKeyDerivation -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.crypto.frost.Frost -import fr.acinq.bitcoin.crypto.frost.IndividualNonce -import fr.acinq.bitcoin.crypto.frost.KeyMaterial -import fr.acinq.bitcoin.crypto.frost.SecretNonce -import fr.acinq.bitcoin.crypto.frost.Session -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * What may be shown as something the group said. - * - * The reading gate is `GroupNostrProfileTest`'s -- the room has to be the author and - * the signature has to be the room's -- and it matters more here than anywhere else - * in this feature. A profile read wrong shows the wrong name; a post read wrong puts - * words in a group's mouth under a byline saying a quorum agreed to them, on a page - * whose whole purpose is to be read by people who were not there. - * - * The ordering half is the other thing worth pinning. Posts are the one statement - * here that **accumulates**: kind:1 is not replaceable, so a second post stands - * beside the first rather than replacing it, and everything the profile and the - * relay lists do with "newest wins" is wrong for these. - */ -class GroupPostTest { - private val participants = 3 - private val threshold = 2 - - private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") - ), - nParticipants = participants, - threshold = threshold - ) - - /** Another group entirely, for the posts signed by the wrong room. */ - private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") - ), - nParticipants = participants, - threshold = threshold - ) - - private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH - - private val chatRoomId = - SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) - - @Test - fun `a kind one the room signed is a post by the group`() { - val post = GroupPost.of( - signedEvent = signed("We have finished the first chapter."), - chatRoomId = chatRoomId - ) - - assertEquals("We have finished the first chapter.", post?.content) - assertEquals(chatRoomId, post?.publicKey) - assertTrue(post?.isNewThread() == true) - } - - @Test - fun `a kind one another group signed is not this group's post`() { - // A real quorum and a real signature by a group with no standing to speak - // for this one. The row names this room because that is where it was filed; - // the author is what decides whose words they are. - val stranger = GroupSignedEvent.fromEvent( - event = noteEvent("Said by somebody else entirely.", material = strangerMaterial), - chatRoomId = chatRoomId - ) - - assertNull(GroupPost.of(signedEvent = stranger, chatRoomId = chatRoomId)) - } - - @Test - fun `a kind one the room did not sign is not a post`() { - // Authored by the room and signed by somebody else: the shape a member - // putting words in the group's mouth would produce, and the reason the - // reading verifies rather than trusting the author field. - assertNull( - GroupPost.of( - signedEvent = signed("The group has decided to disband.", signer = strangerMaterial), - chatRoomId = chatRoomId - ) - ) - - listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> - assertNull( - GroupPost.of( - signedEvent = signed("Anything at all", signature = rubbish), - chatRoomId = chatRoomId - ), - "a post signed with \"$rubbish\" was accepted" - ) - } - } - - @Test - fun `an event of another kind is not a post`() { - assertNull( - GroupPost.of( - signedEvent = signed("Hello").copy(kind = 30023), - chatRoomId = chatRoomId - ) - ) - } - - @Test - fun `posts accumulate rather than replace, newest first`() { - // The property that separates these from the profile and the relay lists. - // A reading that kept only the newest would silently delete a group's whole - // history the first time it posted twice. - val first = signed("First", createdAt = 1_700_000_000) - val second = signed("Second", createdAt = 1_700_000_900) - val third = signed("Third", createdAt = 1_700_001_800) - - listOf( - listOf(first, second, third), - listOf(third, second, first), - listOf(second, third, first), - ).forEach { events -> - assertEquals( - listOf("Third", "Second", "First"), - GroupPost.newestFirstAmong(events, chatRoomId).map { it.content }, - "posts came back in the wrong order, or one of them went missing" - ) - } - } - - @Test - fun `two posts signed in the same second resolve the same way on every device`() { - // Two devices reading the same posts in whatever order the query hands them - // over have to show the same feed. A tie on the timestamp breaks on the id, - // which both agree on because it is a hash of the event. - val one = signed("One", createdAt = 1_700_000_000) - val other = signed("Other", createdAt = 1_700_000_000) - - val expected = GroupPost.newestFirstAmong(listOf(one, other), chatRoomId).map { it.content } - - assertEquals( - expected, - GroupPost.newestFirstAmong(listOf(other, one), chatRoomId).map { it.content }, - "a tie on the timestamp was broken differently by the two orderings" - ) - assertEquals( - if (one.id > other.id) listOf("One", "Other") else listOf("Other", "One"), - expected, - "the tie should break on the id, which is the only thing both devices share" - ) - } - - @Test - fun `a blank post is dropped rather than drawn as an empty card`() { - // The composer will not produce one, so anything reaching here empty came - // from somewhere this app does not control -- and an empty signed card - // cannot be told from a broken one. - listOf("", " ", "\n\n").forEach { blank -> - assertEquals( - emptyList(), - GroupPost.newestFirstAmong(listOf(signed(blank)), chatRoomId), - "a post saying \"$blank\" was shown" - ) - } - } - - @Test - fun `a reply is marked as one`() { - // Nothing here writes replies, so this is about an event that arrived some - // other way. Drawing one as an announcement would put half a conversation on - // a group's page with no sign that it was half. - val reply = GroupPost.of( - signedEvent = signed( - "Yes, agreed.", - tags = arrayOf(arrayOf("e", "b".repeat(64), "", "reply")) - ), - chatRoomId = chatRoomId - ) - - assertFalse(reply?.isNewThread() == true) - } - - @Test - fun `the composer builds a bare note and trims it`() { - // No reply tags, no mentions, no subject: every tag `TextNoteEvent.build` - // can add describes a relationship to something else, which a group's first - // way of posting does not have and should not guess at. - val template = GroupPost.template(" Something the group would say. ") - - assertEquals(TextNoteEvent.KIND, template.kind) - assertEquals("Something the group would say.", template.content) - assertEquals(emptyList(), template.tags.toList()) - } - - @Test - fun `a post the composer built reads back as the group said it`() { - val template = GroupPost.template("We have finished the first chapter.") - val post = GroupPost.of( - signedEvent = signed(template.content, tags = template.tags), - chatRoomId = chatRoomId - ) - - assertEquals("We have finished the first chapter.", post?.content) - assertTrue(post?.isNewThread() == true) - } - - /** A post as `FrostSigningManager.complete` files one. */ - private fun signed( - note: String, - tags: Array> = emptyArray(), - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): GroupSignedEvent = GroupSignedEvent.fromEvent( - event = noteEvent(note, tags, createdAt, material, signer, signature), - chatRoomId = chatRoomId, - derivationPath = SharedKeyDerivation.formatPath(path) - ) - - private fun noteEvent( - note: String, - tags: Array> = emptyArray(), - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): Event { - val groupPubKey = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .hex - - val id = EventHasher.hashId( - pubKey = groupPubKey, - createdAt = createdAt, - kind = TextNoteEvent.KIND, - tags = tags, - content = note - ) - - return Event( - id = id, - pubKey = groupPubKey, - createdAt = createdAt, - kind = TextNoteEvent.KIND, - tags = tags, - content = note, - sig = signature ?: groupSignature(signer, id) - ) - } - - /** - * A real FROST signature by [material]'s quorum over [eventId], through the - * same shape `FrostSigningManager.advance` runs. - */ - private fun groupSignature(material: KeyMaterial, eventId: String): String { - val cache = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .cache - val message = ByteVector(eventId.hexToByteArray()) - val signerIds = listOf(0, 1) - - val nonces = signerIds.map { signerId -> - SecretNonce.generate( - sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), - secretShare = material.secretShares[signerId], - publicShare = material.publicShares[signerId], - tweakedThresholdPublicKey = cache.tweakedPublicKey, - message = message, - extraInput = null - ) - } - - val signingSession = Session.create( - aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = signerIds.map { material.publicShares[it] }, - nParticipants = participants, - threshold = threshold, - tweakCache = cache, - message = message - ) - - val partials = signerIds.mapIndexed { position, signerId -> - signingSession.sign( - nonces[position].first, - material.secretShares[signerId], - signerId.toUInt() - ).right!! - } - - return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt deleted file mode 100644 index e58088ab..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt +++ /dev/null @@ -1,398 +0,0 @@ -package press.mantra.compose.nostr - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.extensions.toHex -import press.mantra.compose.managers.SharedKeyDerivation -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import fr.acinq.bitcoin.ByteVector -import fr.acinq.bitcoin.ByteVector32 -import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.crypto.frost.Frost -import fr.acinq.bitcoin.crypto.frost.IndividualNonce -import fr.acinq.bitcoin.crypto.frost.KeyMaterial -import fr.acinq.bitcoin.crypto.frost.SecretNonce -import fr.acinq.bitcoin.crypto.frost.Session -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * What a group's relay lists are allowed to be read out of, and what an edit - * produces. - * - * The reading half is `GroupNostrProfileTest`'s again -- the room has to be the - * author, the signature has to be the room's -- and it is repeated here because - * these are four different kinds through two different tag vocabularies, and a - * mistake in any one of them would be invisible in the other three. - * - * The writing half is the one that could not be settled by reading the code. - * NIP-65's markers are absent for both, `read` for read-only and `write` for - * write-only, so the tag for the ordinary case is the one with the least in it -- - * exactly the shape a round trip is most likely to lose. And "neither" is a state - * the format cannot express at all, which is why the editor refuses it and why - * building one drops it rather than writing a tag that means the opposite. - */ -class GroupRelayListTest { - private val participants = 3 - private val threshold = 2 - - private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") - ), - nParticipants = participants, - threshold = threshold - ) - - /** Another group entirely, for the lists signed by the wrong room. */ - private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( - thresholdSecretKey = PrivateKey( - ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") - ), - nParticipants = participants, - threshold = threshold - ) - - private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH - - private val chatRoomId = - SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) - - private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") - private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") - - @Test - fun `every set round trips through the tags it is written in`() { - // Four kinds and two tag vocabularies: NIP-65's "r" for General and the - // "relay" tag for the other three, through two RelayTag classes that are - // different classes in different packages. A set wired to the wrong one - // writes tags nothing reads back. - GroupRelaySet.entries.forEach { set -> - val relays = listOf(GroupRelay(one), GroupRelay(two)) - val list = GroupRelayList.of( - signedEvent = signed(set.template(relays)), - chatRoomId = chatRoomId, - set = set - ) - - assertEquals( - listOf(one, two), - list?.relays?.map { it.url }, - "${set.name} did not round trip through its own tags" - ) - } - } - - @Test - fun `a relay list only reads back as the set it was written as`() { - // The kinds are the only thing separating a general list from a blocked - // one, and reading a blocked list as a general one would have the group - // publishing to the relays it refuses to talk to. - val blocked = signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(one)))) - - assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.General)) - assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Search)) - assertEquals( - listOf(one), - GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Blocked)?.relays?.map { it.url } - ) - } - - @Test - fun `read and write markers survive being written and read`() { - // The ordinary case is the dangerous one: NIP-65 writes both by *omitting* - // the marker, so a round trip that lost the third element entirely would - // still look correct for a both-ways relay and would silently turn a - // read-only relay into a write one. - val relays = listOf( - GroupRelay(one, read = true, write = true), - GroupRelay(two, read = true, write = false), - GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example"), - read = false, write = true), - ) - - val readBack = GroupRelayList.of( - signedEvent = signed(GroupRelaySet.General.template(relays)), - chatRoomId = chatRoomId, - set = GroupRelaySet.General - ) - - assertEquals(relays, readBack?.relays) - } - - @Test - fun `a relay that is neither read nor write is not written at all`() { - // NIP-65 has no marker for it. The editor will not produce the state, and - // this is the second line of that defence: writing a bare `r` tag would - // advertise it as both, which is the opposite of what was asked. - val list = GroupRelayList.of( - signedEvent = signed( - GroupRelaySet.General.template( - listOf( - GroupRelay(one, read = false, write = false), - GroupRelay(two), - ) - ) - ), - chatRoomId = chatRoomId, - set = GroupRelaySet.General - ) - - assertEquals(listOf(two), list?.relays?.map { it.url }) - } - - @Test - fun `a relay list another group signed is not this group's`() { - val stranger = GroupSignedEvent.fromEvent( - event = relayEvent( - GroupRelaySet.General.template(listOf(GroupRelay(one))), - material = strangerMaterial - ), - chatRoomId = chatRoomId - ) - - assertNull(GroupRelayList.of(stranger, chatRoomId, GroupRelaySet.General)) - } - - @Test - fun `a relay list the room did not sign is not a relay list`() { - // Authored by the room and signed by somebody else -- the shape a member - // redirecting a group's traffic would produce. - assertNull( - GroupRelayList.of( - signedEvent = signed( - GroupRelaySet.General.template(listOf(GroupRelay(one))), - signer = strangerMaterial - ), - chatRoomId = chatRoomId, - set = GroupRelaySet.General - ) - ) - - listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> - assertNull( - GroupRelayList.of( - signedEvent = signed( - GroupRelaySet.General.template(listOf(GroupRelay(one))), - signature = rubbish - ), - chatRoomId = chatRoomId, - set = GroupRelaySet.General - ), - "a list signed with \"$rubbish\" was accepted" - ) - } - } - - @Test - fun `a set the group never agreed reads back unsigned and empty`() { - // Not null. The screen has a row per set whatever the group has said, and - // `isAgreed` is what separates "never said" from "said nothing". - val list = GroupRelayList.newestAmong(emptyList(), chatRoomId, GroupRelaySet.Search) - - assertFalse(list.isAgreed) - assertEquals(emptyList(), list.relays) - assertEquals(GroupRelaySet.Search, list.set) - assertNull(list.signedAt) - } - - @Test - fun `an agreed empty list is not the same as one never agreed`() { - // A group withdrawing its relay list signs an empty one, and that decision - // has to survive being read back -- otherwise a deliberate withdrawal looks - // exactly like never having got round to it. - val list = GroupRelayList.of( - signedEvent = signed(GroupRelaySet.Messages.template(emptyList())), - chatRoomId = chatRoomId, - set = GroupRelaySet.Messages - ) - - assertTrue(list?.isAgreed == true) - assertEquals(emptyList(), list?.relays) - } - - @Test - fun `the newest list the group signed is the one that counts`() { - val older = signed( - GroupRelaySet.General.template(listOf(GroupRelay(one))), - createdAt = 1_700_000_000 - ) - val newer = signed( - GroupRelaySet.General.template(listOf(GroupRelay(two))), - createdAt = 1_700_000_900 - ) - - listOf(listOf(older, newer), listOf(newer, older)).forEach { events -> - assertEquals( - listOf(two), - GroupRelayList.newestAmong(events, chatRoomId, GroupRelaySet.General) - .relays.map { it.url }, - "the newest relay list lost to query order" - ) - } - } - - @Test - fun `allAmong sorts every set into one entry each`() { - val events = listOf( - signed(GroupRelaySet.General.template(listOf(GroupRelay(one)))), - signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(two)))), - ) - - val all = GroupRelayList.allAmong(events, chatRoomId) - - assertEquals(GroupRelaySet.entries.toList(), all.map { it.set }) - assertEquals(listOf(one), all.first { it.set == GroupRelaySet.General }.relays.map { it.url }) - assertEquals(listOf(two), all.first { it.set == GroupRelaySet.Blocked }.relays.map { it.url }) - assertFalse(all.first { it.set == GroupRelaySet.Search }.isAgreed) - assertFalse(all.first { it.set == GroupRelaySet.Messages }.isAgreed) - } - - @Test - fun `every set but blocked starts at the app's own relay`() { - // What a group setting up for the first time is shown. Seeding with nothing - // would make its first proposal an empty list, which says less than never - // having said anything. - GroupRelaySet.entries.filterNot { it == GroupRelaySet.Blocked }.forEach { set -> - assertEquals( - listOf(Relays.ephemeral), - set.defaults().map { it.url }, - "${set.name} did not default to the app's relay" - ) - } - - // And blocked starts empty: a default there is a refusal to talk to - // somebody that nobody in the group chose. - assertEquals(emptyList(), GroupRelaySet.Blocked.defaults()) - } - - @Test - fun `only a wss relay somewhere other than this device is accepted`() { - assertEquals(one, GroupRelaySet.relayUrlOrNull("wss://relay.one.example")) - assertEquals(one, GroupRelaySet.relayUrlOrNull(" wss://relay.one.example ")) - - // A group's list is read by strangers over the open network: ws:// asks - // them to fetch it in the clear, and loopback names a relay only one device - // can reach. - listOf( - "ws://relay.one.example", - "http://relay.one.example", - "relay.one.example", - "wss://localhost", - "wss://127.0.0.1", - "", - ).forEach { - assertNull(GroupRelaySet.relayUrlOrNull(it), "\"$it\" was accepted as a relay") - } - } - - @Test - fun `a duplicated relay is read once`() { - // Two tags for one relay is one relay said twice, and a list that shows it - // twice reads as two to a human -- the same reasoning a birth certificate's - // admin roster is deduplicated on. - val duplicated = Event( - id = "", - pubKey = chatRoomId, - createdAt = 1_700_000_000, - kind = AdvertisedRelayListEvent.KIND, - tags = arrayOf(arrayOf("r", one.url), arrayOf("r", one.url, "read")), - content = "", - sig = "" - ) - - assertEquals(listOf(one), GroupRelaySet.General.parse(duplicated.tags).map { it.url }) - } - - /** A relay list as `FrostSigningManager.complete` files one. */ - private fun signed( - template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>, - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): GroupSignedEvent = GroupSignedEvent.fromEvent( - event = relayEvent(template, createdAt, material, signer, signature), - chatRoomId = chatRoomId, - derivationPath = SharedKeyDerivation.formatPath(path) - ) - - private fun relayEvent( - template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>, - createdAt: Long = 1_700_000_000, - material: KeyMaterial = groupMaterial, - signer: KeyMaterial = material, - signature: String? = null - ): Event { - val groupPubKey = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .hex - - val id = EventHasher.hashId( - pubKey = groupPubKey, - createdAt = createdAt, - kind = template.kind, - tags = template.tags, - content = template.content - ) - - return Event( - id = id, - pubKey = groupPubKey, - createdAt = createdAt, - kind = template.kind, - tags = template.tags, - content = template.content, - sig = signature ?: groupSignature(signer, id) - ) - } - - /** - * A real FROST signature by [material]'s quorum over [eventId], through the - * same shape `FrostSigningManager.advance` runs. - */ - private fun groupSignature(material: KeyMaterial, eventId: String): String { - val cache = SharedKeyDerivation - .derive(material.thresholdPublicKey.value.toHex(), path) - .cache - val message = ByteVector(eventId.hexToByteArray()) - val signerIds = listOf(0, 1) - - val nonces = signerIds.map { signerId -> - SecretNonce.generate( - sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), - secretShare = material.secretShares[signerId], - publicShare = material.publicShares[signerId], - tweakedThresholdPublicKey = cache.tweakedPublicKey, - message = message, - extraInput = null - ) - } - - val signingSession = Session.create( - aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = signerIds.map { material.publicShares[it] }, - nParticipants = participants, - threshold = threshold, - tweakCache = cache, - message = message - ) - - val partials = signerIds.mapIndexed { position, signerId -> - signingSession.sign( - nonces[position].first, - material.secretShares[signerId], - signerId.toUInt() - ).right!! - } - - return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt deleted file mode 100644 index 1949b9ab..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt +++ /dev/null @@ -1,546 +0,0 @@ -package press.mantra.compose.nostr.curated - -import com.vitorpamplona.quartz.nip01Core.core.Event -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * Kinds 31888 and 31890 as the NIP writes them, read and checked. - * - * The fixtures are the NIP's own examples -- the bitcoin.mov schema, *The Rise - * and Rise of Bitcoin* as `eebb74ab…` suggested it, and the curator signing it - * off -- because the one property worth pinning is that what that site accepts - * is accepted here and what it refuses is refused here for the same reason. - * `CuratedEntryEvent` is a port of the reference module's verifier rule for - * rule, and this is where each rule gets a case. - * - * The other thing pinned is the shape of what comes back: an entry keyed by the - * schema's field names, with the tags the schema does not define ignored rather - * than kept. `director`, `i` and `lang` are on the NIP's example and on no - * field of the NIP's schema, and they are not in the reading. - * - * And the write side is pinned against the same example from the other end: - * the canonical entry built from the suggestion's values is the NIP's canonical - * entry, tag for tag, and what the builder writes is what the reader reads - * back -- which is the property that lets the accept form and the queue screen - * agree about an entry without either seeing the other's code. - */ -class CuratedEntryEventTest { - private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25" - private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - private val stranger = "1".repeat(64) - - private val coordinate = "31889:$curator:bitcoin.mov" - - /** The NIP's schema, as `CuratedSchemaEventTest` reads it. */ - private val schema: CuratedSchema = assertNotNull( - CuratedSchemaEvent.parse( - Event( - id = "a".repeat(64), - pubKey = curator, - createdAt = 1735689600, - kind = CuratedSchemaEvent.KIND, - tags = arrayOf( - arrayOf("d", "bitcoin.mov"), - arrayOf("title", "bitcoin.mov suggestion"), - arrayOf("name", "bitcoin.mov"), - arrayOf("description", "Fields for a bitcoin.mov entry."), - arrayOf("k", "31888"), - arrayOf("visibility", "public"), - arrayOf("relay", "wss://ephemeral.mantra.press"), - arrayOf("field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"), - arrayOf("field", "title", "text", "required", "", "Title", "{\"max\":200}"), - arrayOf("field", "year", "year", "optional", "", "Year", "{\"min\":1900,\"max\":2100}"), - arrayOf("field", "type", "enum", "required", "", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"), - arrayOf("field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"), - arrayOf("field", "imdbUrl", "url", "optional", "", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"), - arrayOf("field", "image", "image", "optional", "", "Poster image URL (https)", "{\"max\":500}"), - arrayOf("field", "hashtags", "token", "optional", "", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"), - arrayOf("field", "description", "longtext", "optional", "", "Description / review", "{\"tag\":\"content\",\"max\":4000}"), - arrayOf("require-any", "watchUrl", "imdbUrl"), - ), - content = "Fields for a bitcoin.mov entry.", - sig = "b".repeat(128), - ) - ) - ) - - private val review = "Follows programmer Daniel Mross and the early Bitcoin community from 2011 onward." - - /** *The Rise and Rise of Bitcoin*, as the NIP's example has it. */ - private val riseAndRise: Array> = arrayOf( - arrayOf("d", "imdb:tt2821314"), - arrayOf("title", "The Rise and Rise of Bitcoin"), - arrayOf("year", "2014"), - arrayOf("type", "documentary"), - arrayOf("director", "Nicholas Mross"), - arrayOf("r", "http://bitcoindoc.com/", "watch"), - arrayOf("r", "https://www.imdb.com/title/tt2821314/", "imdb"), - arrayOf("image", "https://upload.wikimedia.org/wikipedia/en/9/96/The_Rise_and_Rise_of_Bitcoin_%282014%29_Film_Poster.jpg"), - arrayOf("i", "imdb:tt2821314"), - arrayOf("lang", "en"), - arrayOf("t", "bitcoin"), - arrayOf("t", "documentary"), - arrayOf("a", coordinate, "wss://ephemeral.mantra.press", "root"), - arrayOf("p", curator), - arrayOf("k", "31889"), - ) - - private val suggestionId = "a809ddc2ad9c7b99e739e872b1b9e99e96ca9a3a4cb28cdf220c74324277eee8" - - @Test - fun `the NIP's suggestion satisfies the NIP's schema, and reads by field`() { - assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(), schema, coordinate)) - - val entry = assertNotNull(CuratedEntryEvent.suggestion(suggestion(), schema, coordinate)) - - assertEquals(CuratedEntryEvent.SUGGESTION_KIND, entry.kind) - assertEquals(suggester, entry.pubKey) - assertEquals("imdb:tt2821314", entry.identifier) - assertEquals("The Rise and Rise of Bitcoin", entry.title) - assertEquals("31888:$suggester:imdb:tt2821314", entry.coordinate()) - assertNull(entry.source) - - // By the schema's field names, not by tag: the two `r` tags land on - // two fields, the `t` tags on one, and the content on the field that - // writes there. - assertEquals("2014", entry.value("year")) - assertEquals("documentary", entry.value("type")) - assertEquals("http://bitcoindoc.com/", entry.value("watchUrl")) - assertEquals("https://www.imdb.com/title/tt2821314/", entry.value("imdbUrl")) - assertEquals(listOf("bitcoin", "documentary"), entry.values["hashtags"]) - assertEquals(review, entry.value("description")) - - // What the schema does not define is not read, however much of it there is. - assertEquals( - setOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"), - entry.values.keys - ) - } - - @Test - fun `the minimum suggestion is enough`() { - // The doc's own minimum: nothing but what the schema insists on, and an - // IMDb link standing in for a watch link under the require-any rule. - val minimum = suggestion( - tags = arrayOf( - arrayOf("d", "bitcoin-the-end-of-money-as-we-know-it"), - arrayOf("title", "Bitcoin: The End of Money as We Know It"), - arrayOf("type", "documentary"), - arrayOf("r", "https://www.imdb.com/title/tt4654844/", "imdb"), - arrayOf("t", "bitcoin"), - arrayOf("a", coordinate, "", "root"), - ), - content = "" - ) - - assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(minimum, schema, coordinate)) - assertNull(assertNotNull(CuratedEntryEvent.suggestion(minimum, schema, coordinate)).value("description")) - } - - @Test - fun `the NIP's canonical entry is the list's own, and points at where it came from`() { - assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(canonical(), schema, coordinate, curator)) - - val entry = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator)) - - assertTrue(entry.isCanonical) - assertEquals("imdb:tt2821314", entry.identifier) - assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate()) - assertEquals( - CuratedSuggestionRef( - id = suggestionId, - coordinate = "31888:$suggester:imdb:tt2821314", - pubKey = suggester - ), - entry.source - ) - } - - @Test - fun `a canonical entry nobody suggested has no source`() { - val unprompted = canonical(tags = riseAndRise) - - assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(unprompted, schema, coordinate, curator)) - assertNull(assertNotNull(CuratedEntryEvent.canonical(unprompted, schema, coordinate, curator)).source) - } - - @Test - fun `curation is not delegated`() { - // The same event, signed by the suggester: a valid suggestion and not a - // canonical entry, whatever its kind says. - val notTheCurator = canonical(pubKey = suggester) - - assertEquals( - listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator)), - CuratedEntryEvent.verifyCanonical(notTheCurator, schema, coordinate, curator) - ) - assertNull(CuratedEntryEvent.canonical(notTheCurator, schema, coordinate, curator)) - } - - @Test - fun `a source that is not a suggestion coordinate is refused`() { - val malformed = canonical( - tags = riseAndRise + arrayOf(arrayOf("a", "31888:not-a-pubkey:imdb:tt2821314", "", "mention")) - ) - - assertEquals( - listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed)), - CuratedEntryEvent.verifyCanonical(malformed, schema, coordinate, curator) - ) - } - - @Test - fun `the wrong kind is not an entry of either sort`() { - val note = suggestion(kind = 1) - - assertEquals( - listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_KIND, CuratedEntryProblem.Reason.WrongKind)), - CuratedEntryEvent.verifySuggestion(note, schema, coordinate) - ) - // And a suggestion is not a canonical entry: same tags, same author rule aside. - assertTrue( - CuratedEntryEvent.verifyCanonical(suggestion(), schema, coordinate, curator) - .any { it.reason == CuratedEntryProblem.Reason.WrongKind } - ) - } - - @Test - fun `a suggestion has to reply to this schema`() { - assertEquals( - listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply)), - CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("a")), schema, coordinate) - ) - - // Somebody else's list with the same identifier is somebody else's list. - val elsewhere = riseAndRise.replacing("a", "31889:$stranger:bitcoin.mov") - assertEquals( - listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere)), - CuratedEntryEvent.verifySuggestion(suggestion(tags = elsewhere), schema, coordinate) - ) - } - - @Test - fun `a required field has to be there`() { - assertEquals( - listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.Missing)), - CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("title")), schema, coordinate) - ) - assertEquals( - listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.Missing)), - CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("type")), schema, coordinate) - ) - } - - @Test - fun `a field that does not repeat appears once`() { - val twoPosters = riseAndRise + arrayOf(arrayOf("image", "https://example.com/other.jpg")) - assertEquals( - listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.Repeated)), - CuratedEntryEvent.verifySuggestion(suggestion(tags = twoPosters), schema, coordinate) - ) - - // Where the schema says `repeat`, two is fine -- and both are read. - val twoWatchLinks = riseAndRise + arrayOf(arrayOf("r", "https://vimeo.com/1", "watch")) - assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(tags = twoWatchLinks), schema, coordinate)) - assertEquals( - listOf("http://bitcoindoc.com/", "https://vimeo.com/1"), - CuratedEntryEvent.read(suggestion(tags = twoWatchLinks), schema).values["watchUrl"] - ) - } - - @Test - fun `every value has to match its field`() { - fun problemsWith(tag: String, value: String) = - CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.replacing(tag, value)), schema, coordinate) - - // The things the doc lists as rejected, and why they matter. - assertEquals(listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption)), problemsWith("type", "musical")) - assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.BelowMinimum)), problemsWith("year", "1800")) - assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.AboveMaximum)), problemsWith("year", "2200")) - assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber)), problemsWith("year", "twenty fourteen")) - assertEquals(listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.NotHttps)), problemsWith("image", "http://example.com/poster.jpg")) - assertEquals(listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong)), problemsWith("title", "x".repeat(201))) - assertEquals(listOf(CuratedEntryProblem("identifier", CuratedEntryProblem.Reason.NotOneWord)), problemsWith("d", "imdb tt2821314")) - - // A `javascript:` link ends up in an href. Replacing the watch link - // leaves the IMDb one, so require-any is still met and the URL is the - // only complaint. - val scripted = riseAndRise.map { - if (it[0] == "r" && it[2] == "watch") arrayOf("r", "javascript:alert(1)", "watch") else it - }.toTypedArray() - assertEquals( - listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.NotAUrl)), - CuratedEntryEvent.verifySuggestion(suggestion(tags = scripted), schema, coordinate) - ) - } - - @Test - fun `a require-any group needs one of its fields`() { - val noLinks = riseAndRise.filter { it[0] != "r" }.toTypedArray() - - // Filed under the group's first field, so a form can show it inline. - assertEquals( - listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.RequireAnyUnmet)), - CuratedEntryEvent.verifySuggestion(suggestion(tags = noLinks), schema, coordinate) - ) - } - - @Test - fun `a closed list takes suggestions from the curator and the suggesters it names`() { - val closed = schema.copy(visibility = CuratedVisibility.Closed, suggesters = listOf(suggester)) - - assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = suggester), closed, coordinate)) - assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = curator), closed, coordinate)) - assertEquals( - listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)), - CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), closed, coordinate) - ) - - // Private is the same rule; the difference is who is shown the list, not who may write to it. - val private = closed.copy(visibility = CuratedVisibility.Private) - assertEquals( - listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)), - CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), private, coordinate) - ) - } - - @Test - fun `a value is checked by its type and its config`() { - val duration = CuratedField("runtime", CuratedFieldType.Duration, isRequired = false, config = CuratedFieldConfig(max = 36000)) - assertEquals(CuratedEntryProblem.Reason.NotPositive, CuratedEntryEvent.checkValue(duration, "0")) - assertEquals(CuratedEntryProblem.Reason.AboveMaximum, CuratedEntryEvent.checkValue(duration, "36001")) - // A numeric `max` is a bound, not a length: six digits under the bound is fine. - assertNull(CuratedEntryEvent.checkValue(duration, " 5400 ")) - - val pattern = CuratedField("code", CuratedFieldType.Token, isRequired = false, config = CuratedFieldConfig(pattern = "[a-z]{2,8}")) - assertNull(CuratedEntryEvent.checkValue(pattern, "abc")) - // End to end, not anywhere within. - assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(pattern, "abc1")) - - // A pattern this platform cannot compile is a check it cannot make, and - // an entry it cannot check is not one it vouches for. - val broken = pattern.copy(config = CuratedFieldConfig(pattern = "[unclosed")) - assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(broken, "abc")) - - assertEquals(CuratedEntryProblem.Reason.Empty, CuratedEntryEvent.checkValue(pattern, " ")) - - val strict = CuratedField("site", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(https = true)) - assertEquals(CuratedEntryProblem.Reason.NotHttps, CuratedEntryEvent.checkValue(strict, "http://example.com")) - assertNull(CuratedEntryEvent.checkValue(strict, "https://example.com")) - } - - @Test - fun `a coordinate splits on the first two colons only`() { - val parsed = assertNotNull(CuratedCoordinate.parse("31888:${suggester.uppercase()}:imdb:tt2821314")) - - assertEquals(31888, parsed.kind) - assertEquals(suggester, parsed.pubKey, "the pubkey was not lowercased") - assertEquals("imdb:tt2821314", parsed.identifier, "the identifier's own colon split it") - assertEquals("31888:$suggester:imdb:tt2821314", parsed.toValue()) - - assertNull(CuratedCoordinate.parse("31888:$suggester:"), "an empty identifier was accepted") - assertNull(CuratedCoordinate.parse("31888:not-a-pubkey:x")) - assertNull(CuratedCoordinate.parse("naddr1qqxnzd3cxqmrzv3exgmr2wfeqgs"), "a bech32 address is not a coordinate") - } - - // -- the write side ----------------------------------------------------------- - - /** The NIP's example, less the tags its schema does not define, which the builder cannot know to write. */ - private val riseAndRiseAsTheSchemaKnowsIt: List> = riseAndRise - .filterNot { it[0] == "director" || it[0] == "i" || it[0] == "lang" } - .map { it.toList() } - - private val sourceBlock: List> = listOf( - listOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), - listOf("e", suggestionId, "", "mention"), - listOf("p", suggester), - ) - - @Test - fun `the canonical entry built from the NIP's suggestion is the NIP's canonical entry`() { - val suggested = CuratedEntryEvent.read(suggestion(), schema) - - val template = CuratedEntryEvent.canonicalTemplate( - schema = schema, - coordinate = coordinate, - curator = curator, - values = suggested.values, - source = CuratedSuggestionRef.of(suggested), - createdAt = 1735689720, - ) - - assertEquals(CuratedEntryEvent.CANONICAL_KIND, template.kind) - assertEquals(review, template.content) - // The first two blocks identical to the suggestion's, the third the - // pointer back: the doc's own description of the example. - assertEquals(riseAndRiseAsTheSchemaKnowsIt + sourceBlock, template.tags.map { it.toList() }) - - val preview = CuratedEntryEvent.preview(template, curator) - assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator)) - - // And it reads back as what went in, which is the property the form - // and the queue rely on: the values, the `d`, and where it came from. - val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator)) - assertEquals(suggested.values, entry.values) - assertEquals("imdb:tt2821314", entry.identifier) - assertEquals(CuratedSuggestionRef(suggestionId, "31888:$suggester:imdb:tt2821314", suggester), entry.source) - } - - @Test - fun `an edit on the way through is what is signed, and the d is what was suggested`() { - val suggested = CuratedEntryEvent.read(suggestion(), schema) - val corrected = suggested.values + mapOf( - "year" to listOf("2015"), - "image" to listOf("https://example.com/a-better-poster.jpg"), - ) - - val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, corrected, CuratedSuggestionRef.of(suggested)) - val preview = CuratedEntryEvent.preview(template, curator) - - assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator)) - - val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator)) - assertEquals("2015", entry.value("year")) - assertEquals("https://example.com/a-better-poster.jpg", entry.value("image")) - // Same coordinate as before the edit, so re-curating revises rather than adds. - assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate()) - } - - @Test - fun `a value the schema refuses is refused with its field named, not clipped`() { - val suggested = CuratedEntryEvent.read(suggestion(), schema) - val wrong = suggested.values + mapOf( - "year" to listOf("twenty fourteen"), - "title" to listOf("x".repeat(201)), - "type" to listOf("musical"), - ) - - val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, wrong) - val problems = CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) - - assertEquals( - setOf( - CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong), - CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber), - CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption), - ), - problems.toSet() - ) - - // The reference clips a long title to its cap; this does not, because - // the form shows the problem and the member decides how to shorten it. - assertEquals("x".repeat(201), template.tags.first { it[0] == "title" }[1]) - } - - @Test - fun `a blank is not a value, a field that repeats takes all of them, and one that does not takes the first`() { - val template = CuratedEntryEvent.canonicalTemplate( - schema = schema, - coordinate = coordinate, - curator = curator, - values = mapOf( - "identifier" to listOf("imdb:tt2821314"), - "title" to listOf(" The Rise and Rise of Bitcoin ", "A second title nobody asked for"), - "type" to listOf("documentary"), - "year" to listOf(" "), - "watchUrl" to listOf("http://bitcoindoc.com/", "", "https://mirror.example/watch"), - "description" to listOf(""), - ), - ) - - val tags = template.tags.map { it.toList() } - - assertEquals(listOf(listOf("title", "The Rise and Rise of Bitcoin")), tags.filter { it[0] == "title" }) - assertEquals(emptyList(), tags.filter { it[0] == "year" }) - assertEquals( - listOf( - listOf("r", "http://bitcoindoc.com/", "watch"), - listOf("r", "https://mirror.example/watch", "watch"), - ), - tags.filter { it[0] == "r" } - ) - assertEquals("", template.content) - assertEquals( - emptyList(), - CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) - ) - } - - @Test - fun `an entry nobody suggested has no source block, and the root carries the schema's first relay`() { - val values = CuratedEntryEvent.read(suggestion(), schema).values - - val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, values, source = null) - val tags = template.tags.map { it.toList() } - - assertEquals(listOf(listOf("a", coordinate, "wss://ephemeral.mantra.press", "root")), tags.filter { it[0] == "a" }) - assertEquals(listOf(listOf("p", curator)), tags.filter { it[0] == "p" }) - assertEquals(emptyList(), tags.filter { it[0] == "e" }) - - val entry = assertNotNull( - CuratedEntryEvent.canonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) - ) - assertNull(entry.source) - } - - @Test - fun `only a suggestion can be pointed at`() { - val theListsOwn = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator)) - - assertFailsWith { CuratedSuggestionRef.of(theListsOwn) } - } - - @Test - fun `the headline needs no schema`() { - val entry = CuratedEntryEvent.headline(canonical()) - - assertEquals("The Rise and Rise of Bitcoin", entry.title) - assertEquals("imdb:tt2821314", entry.identifier) - assertEquals(suggester, entry.source?.pubKey) - assertEquals(emptyMap(), entry.values) - } - - private fun suggestion( - tags: Array> = riseAndRise, - content: String = review, - kind: Int = CuratedEntryEvent.SUGGESTION_KIND, - pubKey: String = suggester, - ): Event = Event( - id = suggestionId, - pubKey = pubKey, - createdAt = 1735689660, - kind = kind, - tags = tags, - content = content, - sig = "b".repeat(128), - ) - - /** The curator signing off on the suggestion, as the NIP's example has it. */ - private fun canonical( - tags: Array> = riseAndRise + arrayOf( - arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), - arrayOf("e", suggestionId, "", "mention"), - arrayOf("p", suggester), - ), - pubKey: String = curator, - ): Event = Event( - id = "c".repeat(64), - pubKey = pubKey, - createdAt = 1735689720, - kind = CuratedEntryEvent.CANONICAL_KIND, - tags = tags, - content = review, - sig = "b".repeat(128), - ) - - private fun Array>.without(tag: String): Array> = - filter { it[0] != tag }.toTypedArray() - - private fun Array>.replacing(tag: String, value: String): Array> = - map { if (it[0] == tag) arrayOf(tag, value) + it.drop(2) else it }.toTypedArray() -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt deleted file mode 100644 index 0270d58f..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt +++ /dev/null @@ -1,450 +0,0 @@ -package press.mantra.compose.nostr.curated - -import com.vitorpamplona.quartz.nip01Core.core.Event -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * Kind 31889 as the NIP writes it, read and written back. - * - * The fixture is the bitcoin.mov schema from the NIP's own example, tag for tag, - * because the point of publishing a schema is that another client can drive its - * form from it -- so the one thing worth pinning is that what that site published - * reads here, and that what this app signs would read there. - * - * The other half is the rejection list. A schema is what suggestions are checked - * against, and a reader that repaired a broken one would be accepting entries - * against a form nobody signed. Every rule the NIP says a client MUST refuse on - * has a case here, and so does the one repair it says a client MUST make. - */ -class CuratedSchemaEventTest { - private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25" - - private val description = "Fields for a bitcoin.mov entry: a Bitcoin movie, documentary, " + - "short, interview or series, with at least one link to watch or look it up." - - /** The NIP's example, as published. */ - private val bitcoinMov: Array> = arrayOf( - arrayOf("d", "bitcoin.mov"), - arrayOf("title", "bitcoin.mov suggestion"), - arrayOf("name", "bitcoin.mov"), - arrayOf("description", description), - arrayOf("k", "31888"), - arrayOf("visibility", "public"), - arrayOf("picture", "https://bitcoin.mov/icon.svg"), - arrayOf("domain", "bitcoin.mov"), - arrayOf("relay", "wss://ephemeral.mantra.press"), - arrayOf("field", "identifier", "token", "required", "the-rise-and-rise-of-bitcoin-2014", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"), - arrayOf("field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"), - arrayOf("field", "year", "year", "optional", "2014", "Year", "{\"min\":1900,\"max\":2100}"), - arrayOf("field", "type", "enum", "required", "documentary", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"), - arrayOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"), - arrayOf("field", "imdbUrl", "url", "optional", "https://imdb.com/title/tt2821314", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"), - arrayOf("field", "image", "image", "optional", "https://…/poster.jpg", "Poster image URL (https)", "{\"max\":500}"), - arrayOf("field", "hashtags", "token", "optional", "bitcoin", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"), - arrayOf("field", "description", "longtext", "optional", "Why is this worth watching?", "Description / review", "{\"tag\":\"content\",\"max\":4000}"), - arrayOf("require-any", "watchUrl", "imdbUrl"), - ) - - @Test - fun `the bitcoin_mov schema reads as published`() { - val schema = assertNotNull(CuratedSchemaEvent.parse(event())) - - assertEquals("bitcoin.mov", schema.identifier) - assertEquals("bitcoin.mov suggestion", schema.title) - assertEquals("bitcoin.mov", schema.name) - assertEquals(description, schema.description) - assertEquals(CuratedVisibility.Public, schema.visibility) - assertEquals("https://bitcoin.mov/icon.svg", schema.picture) - assertEquals("bitcoin.mov", schema.domain) - assertEquals(31888, schema.suggestionKind) - assertEquals(listOf("wss://ephemeral.mantra.press"), schema.relays) - assertEquals(listOf(listOf("watchUrl", "imdbUrl")), schema.requireAny) - assertEquals(emptyList(), schema.suggesters) - - assertEquals( - listOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"), - schema.fields.map { it.name } - ) - - // The positional parts and the config, on the field that uses most of them. - val watchUrl = schema.fields.first { it.name == "watchUrl" } - assertEquals(CuratedFieldType.Url, watchUrl.type) - assertFalse(watchUrl.isRequired) - assertEquals("https://youtube.com/watch?v=…", watchUrl.placeholder) - assertEquals("Watch / reference URL", watchUrl.label) - assertEquals("r", watchUrl.tag()) - assertEquals("watch", watchUrl.config.marker) - assertEquals(500L, watchUrl.config.max) - assertEquals(true, watchUrl.config.repeat) - - val type = schema.fields.first { it.name == "type" } - assertEquals(listOf("movie", "documentary", "short", "interview", "series", "other"), type.config.options) - - val identifier = schema.fields.first { it.name == "identifier" } - assertEquals("d", identifier.tag()) - assertEquals(true, identifier.config.derived) - assertTrue(identifier.isRequired) - - // Derived fields are filled in by the client, so a form does not ask. - assertEquals( - listOf("title", "year", "type", "watchUrl", "imdbUrl", "image", "description"), - schema.formFields().map { it.name } - ) - } - - @Test - fun `the domain stands in for the name wherever the list is shown`() { - val schema = assertNotNull(CuratedSchemaEvent.parse(event())) - assertEquals("bitcoin.mov", schema.displayName()) - - val withoutDomain = assertNotNull( - CuratedSchemaEvent.parse(event(tags = bitcoinMov.without("domain").replacing("name", "Bitcoin on screen"))) - ) - assertEquals("Bitcoin on screen", withoutDomain.displayName()) - } - - @Test - fun `a schema round trips through the template`() { - val read = assertNotNull(CuratedSchemaEvent.parse(event())) - - val template = CuratedSchemaEvent.template(read, createdAt = 1735689600) - assertEquals(CuratedSchemaEvent.KIND, template.kind) - // The content mirrors the description, for clients that read content. - assertEquals(description, template.content) - - val reread = assertNotNull(CuratedSchemaEvent.parse(event(tags = template.tags, content = template.content))) - assertEquals(read, reread) - - // And the tags come out in the NIP's order, identity first. - assertEquals( - listOf("d", "title", "name", "description", "k", "visibility", "picture", "domain"), - template.tags.take(8).map { it[0] } - ) - assertEquals(9, template.tags.count { it[0] == "field" }) - assertEquals(listOf("require-any", "watchUrl", "imdbUrl"), template.tags.first { it[0] == "require-any" }.toList()) - assertEquals(listOf("relay", "wss://ephemeral.mantra.press"), template.tags.last().toList()) - } - - @Test - fun `a field tag is written the way the NIP shows it`() { - val schema = assertNotNull(CuratedSchemaEvent.parse(event())) - val tag = schema.fields.first { it.name == "watchUrl" }.toTagArray() - - assertEquals( - listOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL", - "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"), - tag.toList() - ) - } - - @Test - fun `a config key this app does not know survives a round trip`() { - // Other clients legitimately add their own. Editing a field here must - // not strip what one of them meant by it. - val field = assertNotNull( - CuratedField.parse(arrayOf("field", "lang", "token", "optional", "en", "Language", "{\"max\":60,\"locale\":\"bcp47\"}")) - ) - - assertEquals(60L, field.config.max) - assertEquals("\"bcp47\"", field.config.others["locale"].toString()) - assertEquals("{\"max\":60,\"locale\":\"bcp47\"}", field.config.toJson()) - } - - @Test - fun `a malformed config costs the field its constraints and not its life`() { - listOf("{not json", "[1,2]", "\"a string\"", "42", "").forEach { blob -> - val field = assertNotNull( - CuratedField.parse(arrayOf("field", "year", "year", "optional", "2014", "Year", blob)), - "the field was dropped over the config \"$blob\"" - ) - assertEquals(CuratedFieldConfig(), field.config, "the config \"$blob\" read as something") - } - - // A known key of the wrong type is dropped rather than kept as something - // it is not; the rest of the blob is kept. - val field = assertNotNull( - CuratedField.parse(arrayOf("field", "year", "year", "optional", "", "", "{\"max\":\"lots\",\"min\":1900}")) - ) - assertNull(field.config.max) - assertEquals(1900L, field.config.min) - } - - @Test - fun `a field with an unknown type or no name is dropped and the rest are kept`() { - val schema = assertNotNull( - CuratedSchemaEvent.parse( - event( - tags = bitcoinMov.plus( - listOf( - arrayOf("field", "rating", "stars", "optional", "", "", "{}"), - arrayOf("field", " ", "text", "optional", "", "", "{}"), - arrayOf("field", "short"), - ) - ) - ) - ) - ) - - assertEquals(9, schema.fields.size) - } - - @Test - fun `a field missing its trailing positions still reads`() { - val field = assertNotNull(CuratedField.parse(arrayOf("field", "director", "text"))) - - assertEquals("director", field.name) - assertFalse(field.isRequired) - assertEquals("", field.placeholder) - assertEquals("director", field.labelOrName()) - assertEquals(CuratedFieldConfig(), field.config) - } - - @Test - fun `a schema missing an identity tag is not usable`() { - mapOf( - "d" to CuratedSchemaProblem.IdentifierMissing, - "title" to CuratedSchemaProblem.TitleMissing, - "name" to CuratedSchemaProblem.NameMissing, - ).forEach { (tag, problem) -> - val tags = without(tag) - assertNull(CuratedSchemaEvent.parse(event(tags = tags)), "a schema without \"$tag\" was accepted") - assertEquals(listOf(problem), CuratedSchemaEvent.read(tags, description).problems()) - } - - // The description alone has a fallback: the content, which is where - // generic clients put it. Empty both and it is missing. - assertNull(CuratedSchemaEvent.parse(event(tags = without("description"), content = ""))) - assertEquals( - listOf(CuratedSchemaProblem.DescriptionMissing), - CuratedSchemaEvent.read(without("description"), " ").problems() - ) - } - - @Test - fun `the description falls back to the content and the tag wins when both are there`() { - val fromContent = assertNotNull( - CuratedSchemaEvent.parse(event(tags = without("description"), content = " What the content says. ")) - ) - assertEquals("What the content says.", fromContent.description) - - val fromTag = assertNotNull(CuratedSchemaEvent.parse(event(content = "Something else entirely"))) - assertEquals(description, fromTag.description) - } - - @Test - fun `visibility is never guessed`() { - assertNull(CuratedSchemaEvent.parse(event(tags = without("visibility")))) - assertEquals( - listOf(CuratedSchemaProblem.VisibilityMissing), - CuratedSchemaEvent.read(without("visibility"), description).problems() - ) - - assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", "friends")))) - assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", "")))) - - // Case and whitespace are forgiven; a fourth word is not. - assertEquals(CuratedVisibility.Closed, CuratedSchemaEvent.parse(event(tags = replacing("visibility", " Closed ")))?.visibility) - assertEquals(CuratedVisibility.Private, CuratedSchemaEvent.parse(event(tags = replacing("visibility", "PRIVATE")))?.visibility) - } - - @Test - fun `a schema with no fields is not usable`() { - val tags = bitcoinMov.filterNot { it[0] == "field" }.toTypedArray() - - assertNull(CuratedSchemaEvent.parse(event(tags = tags))) - assertEquals(listOf(CuratedSchemaProblem.NoFields), CuratedSchemaEvent.read(tags, description).problems()) - - // Checked before the mandatory fields are put back, which is the NIP's - // rule: "no field tags" is about what the publisher said. - val onlyUnparseable = tags.plus(arrayOf("field", "rating", "stars", "optional", "", "", "{}")) - assertNull(CuratedSchemaEvent.parse(event(tags = onlyUnparseable))) - } - - @Test - fun `a picture that is not https is not usable`() { - listOf("http://bitcoin.mov/icon.svg", "bitcoin.mov/icon.svg", "javascript:alert(1)", "https://").forEach { picture -> - assertNull( - CuratedSchemaEvent.parse(event(tags = replacing("picture", picture))), - "a schema with the picture \"$picture\" was accepted" - ) - } - - // Absent is fine; it is optional. - assertNull(CuratedSchemaEvent.parse(event(tags = without("picture")))?.picture) - assertTrue(CuratedSchemaEvent.read(without("picture"), description).isUsable()) - } - - @Test - fun `a domain that is not a hostname is not usable, and one that is gets normalized`() { - listOf("bitcoin", "localhost", "bit coin.mov", "-bitcoin.mov", "bitcoin_mov.example").forEach { domain -> - assertNull( - CuratedSchemaEvent.parse(event(tags = replacing("domain", domain))), - "a schema with the domain \"$domain\" was accepted" - ) - } - - // Lenient on input, strict on what is kept: the things people paste - // around a hostname come off, and what is left is what is checked. - assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "https://Bitcoin.MOV/")))?.domain) - assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "bitcoin.mov:8080/x y")))?.domain) - assertEquals("bitcoin.mov", CuratedSchemaEvent.normalizeDomain("@Bitcoin.mov:443/path.")) - assertTrue(CuratedSchemaEvent.isDomain("sub.example.co.za")) - assertFalse(CuratedSchemaEvent.isDomain("example")) - } - - @Test - fun `a relay that is not a relay is not usable`() { - listOf("https://ephemeral.mantra.press", "ephemeral.mantra.press", "wss://").forEach { relay -> - assertNull( - CuratedSchemaEvent.parse(event(tags = replacing("relay", relay))), - "a schema naming the relay \"$relay\" was accepted" - ) - } - - // The NIP allows ws://, whatever this app will let a group sign. - assertEquals(listOf("ws://localhost:7777"), CuratedSchemaEvent.parse(event(tags = replacing("relay", "ws://localhost:7777")))?.relays) - - // Duplicates collapse; none at all is allowed. - assertEquals( - listOf("wss://ephemeral.mantra.press"), - CuratedSchemaEvent.parse(event(tags = bitcoinMov.plus(arrayOf("relay", "wss://ephemeral.mantra.press"))))?.relays - ) - assertEquals(emptyList(), CuratedSchemaEvent.parse(event(tags = without("relay")))?.relays) - } - - @Test - fun `an over-long identity tag is not usable`() { - assertNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(101))))) - assertNull(CuratedSchemaEvent.parse(event(tags = replacing("d", "d".repeat(101))))) - assertNull(CuratedSchemaEvent.parse(event(tags = replacing("title", "t".repeat(201))))) - assertNull(CuratedSchemaEvent.parse(event(tags = replacing("description", "x".repeat(501))))) - - assertNotNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(100))))) - } - - @Test - fun `the wrong kind is not a schema whatever its tags say`() { - assertNull(CuratedSchemaEvent.parse(event(kind = 31888))) - assertNull(CuratedSchemaEvent.parse(event(kind = 30023))) - } - - @Test - fun `the identifier and the title are forced in whatever the schema says`() { - // A schema fetched from a hostile relay cannot talk a client into - // accepting untitled or unaddressable entries. - val onlyAYear = arrayOf( - arrayOf("d", "years"), - arrayOf("title", "A year"), - arrayOf("name", "Years"), - arrayOf("description", "Just years."), - arrayOf("visibility", "public"), - arrayOf("field", "year", "year", "optional", "", "", "{}"), - ) - - val schema = assertNotNull(CuratedSchemaEvent.parse(event(tags = onlyAYear))) - - assertEquals(listOf("d", "title", "year"), schema.fields.map { it.tag() }) - assertTrue(schema.fields[0].isRequired) - assertTrue(schema.fields[1].isRequired) - assertEquals(CuratedField.IDENTIFIER, schema.fields[0]) - assertEquals(CuratedField.TITLE, schema.fields[1]) - - // And a title the publisher relaxed is made required again, in place. - val relaxed = replacing( - "field", - "title", - arrayOf("field", "title", "text", "optional", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}") - ) - val repaired = assertNotNull(CuratedSchemaEvent.parse(event(tags = relaxed))) - assertEquals(9, repaired.fields.size) - assertTrue(repaired.fields.first { it.name == "title" }.isRequired) - - // Running it again changes nothing. - assertEquals(repaired, repaired.normalized()) - } - - @Test - fun `a require-any naming fewer than two fields says nothing`() { - val schema = assertNotNull( - CuratedSchemaEvent.parse( - event( - tags = bitcoinMov.plus( - listOf( - arrayOf("require-any", "image"), - arrayOf("require-any"), - arrayOf("require-any", "year", " ", "type"), - ) - ) - ) - ) - ) - - assertEquals(listOf(listOf("watchUrl", "imdbUrl"), listOf("year", "type")), schema.requireAny) - } - - @Test - fun `suggesters are the p tags that are pubkeys`() { - val other = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - val schema = assertNotNull( - CuratedSchemaEvent.parse( - event( - tags = replacing("visibility", "closed").plus( - listOf( - arrayOf("p", other), - arrayOf("p", other.uppercase()), - arrayOf("p", "not a pubkey"), - ) - ) - ) - ) - ) - - assertEquals(listOf(other), schema.suggesters) - } - - @Test - fun `the coordinate is what a suggestion replies to`() { - assertEquals("31889:$curator:bitcoin.mov", CuratedSchemaEvent.coordinate(curator, "bitcoin.mov")) - - // An identifier may itself hold colons, so the coordinate does too. - assertEquals("31889:$curator:imdb:tt2821314", CuratedSchemaEvent.coordinate(curator, "imdb:tt2821314")) - } - - @Test - fun `an empty config is written as an empty object`() { - assertEquals("{}", CuratedFieldConfig().toJson()) - assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", CuratedField.IDENTIFIER.config.toJson()) - } - - private fun event( - tags: Array> = bitcoinMov, - content: String = description, - kind: Int = CuratedSchemaEvent.KIND, - ): Event = Event( - id = "a".repeat(64), - pubKey = curator, - createdAt = 1735689600, - kind = kind, - tags = tags, - content = content, - sig = "b".repeat(128), - ) - - private fun without(tag: String): Array> = bitcoinMov.without(tag) - - private fun replacing(tag: String, value: String): Array> = bitcoinMov.replacing(tag, value) - - private fun replacing(tag: String, name: String, with: Array): Array> = - bitcoinMov.map { if (it[0] == tag && it[1] == name) with else it }.toTypedArray() - - private fun Array>.without(tag: String): Array> = - filter { it[0] != tag }.toTypedArray() - - private fun Array>.replacing(tag: String, value: String): Array> = - map { if (it[0] == tag) arrayOf(tag, value) else it }.toTypedArray() -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 2de50968..9ecddf7e 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -1,119 +1,19 @@ package press.mantra.compose.text -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedSchemaEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlin.test.Test import kotlin.test.assertEquals /** - * How the events a group signs about itself are said to the member signing them. + * How an event a group signs is said to the member signing it. * - * These are read on the signing screen, in the room's list of proposals and now - * on the paste screen before proposing, and the same words in all three is the - * point of there being one mapping. What is pinned is that each kind is said as - * the thing it is and by the part of it a signer is actually deciding about -- - * a profile by its name, a post by its words, a relay list by its hosts, a - * schema by its list -- and never as "kind 0" over a wall of JSON. + * These are read on the signing screen and in the room's list of proposals, and + * the same words in both is the point of there being one mapping. What is pinned + * here is the fallback: a kind the mapping has no words for is named by its + * number over its content, and never described wrongly. */ class ProposedEventTest { - @Test - fun `a profile is said by its name and what it says about itself`() { - val summary = ProposedEvent.summarize( - event(MetadataEvent.KIND, content = """{"name":"Translation collective","about":"We translate hard books."}""") - ) - - assertEquals("Nostr profile", summary.label) - assertEquals("Translation collective · We translate hard books.", summary.detail) - - // Empty and unreadable are different things, and a signer should know which. - assertEquals("An empty profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "{}")).detail) - assertEquals("Unreadable profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "not json")).detail) - } - - @Test - fun `a post is said by its words`() { - val summary = ProposedEvent.summarize(event(TextNoteEvent.KIND, content = "We have finished the first chapter.")) - - assertEquals("Post", summary.label) - assertEquals("We have finished the first chapter.", summary.detail) - } - - @Test - fun `a relay list is said by which list it is and its hosts`() { - val summary = ProposedEvent.summarize( - event( - AdvertisedRelayListEvent.KIND, - tags = arrayOf(arrayOf("r", "wss://relay.one.example"), arrayOf("r", "wss://relay.two.example", "read")) - ) - ) - - assertEquals("General relay list", summary.label) - assertEquals("relay.one.example, relay.two.example", summary.detail) - - // An empty list is a withdrawal, which is worth saying in as many words. - assertEquals("No relays", ProposedEvent.summarize(event(AdvertisedRelayListEvent.KIND)).detail) - } - - @Test - fun `a schema is said by its list, who may suggest, and how much an entry asks for`() { - val summary = ProposedEvent.summarize( - event( - CuratedSchemaEvent.KIND, - tags = arrayOf( - arrayOf("d", "films"), - arrayOf("title", "Film suggestion"), - arrayOf("name", "Films worth translating"), - arrayOf("description", "Films the group would subtitle."), - arrayOf("visibility", "closed"), - arrayOf("field", "title", "text", "required", "", "Title", "{}"), - arrayOf("field", "year", "year", "optional", "", "Year", "{}"), - ), - content = "Films the group would subtitle." - ) - ) - - assertEquals("Curated schema", summary.label) - assertEquals("Films worth translating · closed · 2 fields", summary.detail) - } - - @Test - fun `a curated entry is said by its title and who suggested it`() { - val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - - val summary = ProposedEvent.summarize( - event( - CuratedEntryEvent.CANONICAL_KIND, - tags = arrayOf( - arrayOf("d", "imdb:tt2821314"), - arrayOf("title", "The Rise and Rise of Bitcoin"), - arrayOf("year", "2014"), - arrayOf("a", "31889:${"7".repeat(64)}:bitcoin.mov", "", "root"), - arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), - arrayOf("p", suggester), - ), - content = "Follows the early Bitcoin community." - ) - ) - - assertEquals("Curated entry", summary.label) - assertEquals("The Rise and Rise of Bitcoin · suggested by eebb74ab", summary.detail) - - // An entry the group added itself has nobody to credit. - val unprompted = ProposedEvent.summarize( - event( - CuratedEntryEvent.CANONICAL_KIND, - tags = arrayOf(arrayOf("d", "magic-money"), arrayOf("title", "Magic Money")), - content = "" - ) - ) - assertEquals("Magic Money", unprompted.detail) - } - @Test fun `a kind with no words of its own keeps its number`() { // Refusing to describe an event is better than describing it wrongly. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt deleted file mode 100644 index 860aef0e..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt +++ /dev/null @@ -1,216 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextReplacement -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.database.model.intermdiate.LocalNostrEvent -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState -import kotlin.test.Test -import kotlin.time.Instant - -/** - * What the accept form puts on the screen, for a schema it has never seen. - * - * Three things worth pinning. **The inputs are the schema's**: one per field - * the form asks for, labelled as the schema labels them and seeded with what - * was suggested, with an enum as its options rather than a box to misspell - * one in. **What is kept is shown, not hidden**: the identifier and the - * derived fields are on the screen under their own heading, so what the - * member sees is what the group will sign. And **a refusal is drawn under the - * input it is about**, since the button doing nothing is indistinguishable - * from a missed tap. - */ -@OptIn(ExperimentalTestApi::class) -class AcceptCuratedSuggestionScreenJvmTest { - - private val chatRoomId = "d4c3b2a1".repeat(8) - private val ada = "a".repeat(64) - private val bob = "b".repeat(64) - - private val schema = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - domain = "films.example", - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), - CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))), - CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)), - CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)), - CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG, hint = "Why the group should watch it")), - ), - relays = listOf("wss://relay.one.example") - ).normalized() - ) - - private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf( - schema, - listOf( - LocalNostrEvent( - nostrEvent = NostrEvent( - id = "1".repeat(64), - pubKey = bob, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = arrayOf( - arrayOf("d", "magic-money"), - arrayOf("title", "Magic Money"), - arrayOf("year", "2018"), - arrayOf("type", "documentary"), - arrayOf("r", "https://example.com/watch", "watch"), - arrayOf("t", "bitcoin"), - arrayOf("a", schema.coordinate(), "", "root"), - ), - content = "Follows the early community.", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ), - profile = null - ) - ) - ).single() - - private fun state(canSign: Boolean = true, isCurated: Boolean = false) = AcceptCuratedSuggestionUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = ada, - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - schema = schema, - suggestion = suggestion.copy(isCurated = isCurated), - canSign = canSign - ) - - @Test - fun `the inputs are the schema's, seeded from the suggestion, and what is kept is shown`() = render(state()) { - // Twice: the heading, and the title input seeded with it. - onAllNodesWithText("Magic Money").assertCountEquals(2) - onNodeWithText("Suggested by ${bob.take(8)} on ", substring = true).assertIsDisplayed() - onNodeWithText("Take this suggestion into films.example", substring = true).assertIsDisplayed() - - // The schema's labels over its values: a text field, a number, and the - // enum as chips with the suggested one among them. - onNodeWithText("Year").assertIsDisplayed() - onNodeWithText("2018").assertIsDisplayed() - onNodeWithText("Type").assertIsDisplayed() - onNodeWithText("movie").assertIsDisplayed() - onNodeWithText("documentary").assertIsDisplayed() - onNodeWithText("https://example.com/watch").assertIsDisplayed() - onNodeWithText("Follows the early community.").assertIsDisplayed() - // The schema's hint under the input it is for. - onNodeWithText("Why the group should watch it").assertIsDisplayed() - // A field that repeats can be given another value. - onNodeWithText("Add another").assertIsDisplayed() - - // Kept, and said to be: the identifier and the derived hashtags. - onNodeWithText("Kept from the suggestion").assertIsDisplayed() - onNodeWithText("Identifier").assertIsDisplayed() - onNodeWithText("magic-money").assertIsDisplayed() - onNodeWithText("Hashtags").assertIsDisplayed() - onNodeWithText("bitcoin").assertIsDisplayed() - - onNodeWithText("Propose entry").assertIsDisplayed() - onAllNodesWithText("The group already has an entry for this", substring = true).assertCountEquals(0) - } - - @Test - fun `a refusal is drawn under the input it is about`() = render(state()) { - // The suggested year is replaced with one the schema's range refuses, - // by way of the field: the test types where a member would. - onNodeWithText("2018").performTextReplacement("1850") - onNodeWithText("Propose entry").performClick() - - onNodeWithText("At least 1900.").assertIsDisplayed() - } - - @Test - fun `a device without a share sees why the button is inert`() = render(state(canSign = false)) { - onNodeWithText("This group has no shared key, so it cannot sign an entry", substring = true).assertIsDisplayed() - onNodeWithText("Propose entry").assertIsNotEnabled() - } - - @Test - fun `a suggestion the group already took up says so before the button`() = render(state(isCurated = true)) { - onNodeWithText("The group already has an entry for this", substring = true).assertIsDisplayed() - } - - /** - * The screen at a phone's width and a window tall enough to compose the - * whole of it. The state is passed in, so the NO_OP repositories are never - * asked for anything: `initiate` only runs for a screen that arrives loading. - */ - private fun render( - uiState: AcceptCuratedSuggestionUIState, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2400) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - AcceptCuratedSuggestionScreen( - activeUserPublicKey = ada, - chatRoomId = chatRoomId, - relayHint = null, - identifier = "films", - suggestionId = suggestion.id, - initialAcceptCuratedSuggestionUIState = uiState, - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateBack = {}, - onNavigateToRouteAndPopUpInclusive = {} - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt index 327f695d..710d0220 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt @@ -7,13 +7,14 @@ import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.runDesktopComposeUiTest -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -26,9 +27,6 @@ import press.mantra.compose.network.dto.RelayDTO import press.mantra.compose.network.relays.EventPublishTransport import press.mantra.compose.network.relays.NostrPublishResult import press.mantra.compose.network.sockets.NostrIncomingMessage -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost @@ -41,19 +39,21 @@ import kotlin.time.Instant /** * What the broadcast screen shows before, and after, the button. * - * Before: the event named as the signing screen would name it, the relays the - * group has said it lives at already in the list, and a way to add one. After: - * each relay's answer under its own row, in the relay's words where it gave - * any, and the sum above them -- because the sum is what the member came for and - * the words are what they can act on. + * Before: the event named as the signing screen would name it, the app's own + * publish relays already in the list, and a way to add one. After: each relay's + * answer under its own row, in the relay's words where it gave any, and the sum + * above them -- because the sum is what the member came for and the words are + * what they can act on. */ @OptIn(ExperimentalTestApi::class) class BroadcastGroupSignedEventScreenJvmTest { private val chatRoomId = "d4c3b2a1".repeat(8) - private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") - private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + /** The first two relays the screen seeds, in the order it lists them. */ + private val seeded = Relays.eventPublishRelaySet.toList() + private val one = seeded[0] + private val two = seeded[1] /** A pool that answers as told and never opens a socket. */ private class FakeTransport( @@ -66,21 +66,24 @@ class BroadcastGroupSignedEventScreenJvmTest { } @Test - fun `the event is named, the group's relays are filled in, and the JSON is there to copy`() = + fun `the event is named, the app's relays are filled in, and the JSON is there to copy`() = render(state()) { - onNodeWithText("Post").assertIsDisplayed() + // A plain note, which the signing screen has no words of its own + // for, so it is named by its number over its content. + onNodeWithText("Event of kind 1").assertIsDisplayed() onNodeWithText("The first chapter is out.").assertIsDisplayed() - // The group's own General list, as hosts. - onNodeWithText("relay.one.example").assertIsDisplayed() - onNodeWithText("relay.two.example").assertIsDisplayed() + // The app's own publish set, as hosts. + seeded.forEach { relay -> + onNodeWithText(relay.displayUrl()).assertIsDisplayed() + } onNodeWithText("Broadcast").assertIsDisplayed() onNodeWithText("Copy raw JSON").assertIsDisplayed() // The relays come before the JSON: the list is the screen's job and // the JSON is for whoever would rather use a different tool. - val relays = onNodeWithText("relay.one.example").getBoundsInRoot().top + val relays = onNodeWithText(one.displayUrl()).getBoundsInRoot().top val json = onNodeWithText("The signed event").getBoundsInRoot().top assertTrue(relays < json, "the JSON climbed above the relay list") } @@ -120,26 +123,32 @@ class BroadcastGroupSignedEventScreenJvmTest { ) { onNodeWithContentDescription("Broadcast to these relays").performClick() + // The two that answered are counted; the rest of the seeded list + // never did, and the sum says so by counting them as asked. waitUntil(timeoutMillis = 5_000) { - onAllNodesWithText("Accepted by 1 of 2 relays.").fetchSemanticsNodes().isNotEmpty() + onAllNodesWithText("Accepted by 1 of ${seeded.size} relays.") + .fetchSemanticsNodes().isNotEmpty() } onNodeWithText("Accepted").assertIsDisplayed() onNodeWithText("Rejected: blocked: not on the allow list").assertIsDisplayed() // The answer under the relay it came from, not in a list of its own. - val relayOne = onNodeWithText("relay.one.example").getBoundsInRoot().top + val relayOne = onNodeWithText(one.displayUrl()).getBoundsInRoot().top val accepted = onNodeWithText("Accepted").getBoundsInRoot().top - val relayTwo = onNodeWithText("relay.two.example").getBoundsInRoot().top + val relayTwo = onNodeWithText(two.displayUrl()).getBoundsInRoot().top val rejected = onNodeWithText("Rejected: blocked: not on the allow list").getBoundsInRoot().top assertTrue(relayOne < accepted && accepted < relayTwo, "the OK is not under its relay") assertTrue(relayTwo < rejected, "the rejection is not under its relay") } @Test - fun `an empty list asks nobody and says to add one`() = render( - state(relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId), seedNothing = true) - ) { + fun `an empty list asks nobody and says to add one`() = render(state()) { + // Emptied by hand, since the seed always has something in it. + repeat(seeded.size) { + onAllNodesWithContentDescription("Remove relay")[0].performClick() + } + onNodeWithText("No relays to broadcast to. Add one above.").assertIsDisplayed() } @@ -154,37 +163,7 @@ class BroadcastGroupSignedEventScreenJvmTest { createdAt = Instant.fromEpochSeconds(1_700_000_000), ) - /** A General list of two relays, agreed, so the screen has something of the group's to fill in. */ - private fun agreedRelayLists(): List = - GroupRelayList.allAmong(emptyList(), chatRoomId).map { list -> - if (list.set != GroupRelaySet.General) return@map list - - GroupRelayList( - set = list.set, - signedEvent = GroupSignedEvent( - id = "a".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = list.set.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ), - relays = listOf(GroupRelay(one), GroupRelay(two)), - ) - } - - /** - * [seedNothing] blocks every relay the seed would otherwise fall back to, so - * the screen can be seen with an empty list -- a group that has agreed - * nothing is filled in from the app's own set, which is the point of the - * fallback and the opposite of what this fixture wants. - */ - private fun state( - relayLists: List = agreedRelayLists(), - seedNothing: Boolean = false, - ) = BroadcastGroupSignedEventUIState.Loaded( + private fun state() = BroadcastGroupSignedEventUIState.Loaded( localChatRoom = LocalChatRoom( chatRoom = ChatRoom( id = chatRoomId, @@ -196,26 +175,13 @@ class BroadcastGroupSignedEventScreenJvmTest { ) ), signedEvent = signedEvent(), - relayLists = if (seedNothing) { - relayLists.map { list -> - if (list.set != GroupRelaySet.Blocked) return@map list - - GroupRelayList( - set = list.set, - signedEvent = null, - relays = Relays.eventPublishRelaySet.map { GroupRelay(it) }, - ) - } - } else { - relayLists - }, ) private fun render( uiState: BroadcastGroupSignedEventUIState, transport: EventPublishTransport = FakeTransport(emptyList()), assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 1600) { + ) = runDesktopComposeUiTest(width = 400, height = 2400) { setContent { MantraTheme { ProvideSnackbarHost { diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt deleted file mode 100644 index db5ba06c..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt +++ /dev/null @@ -1,333 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.Profile -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * What the queue screen says, in each of the states it can be in. - * - * Three things are worth pinning. **The row is driven by a schema the screen - * has never seen**, so what it puts on a row is a decision -- the fields that - * say what an entry is, labelled, and not the links -- and that decision is - * checked against a schema with one of each. **"Curated" is per row**, and a - * screen that marked the wrong row would send a member to re-curate what the - * group had already taken up. And **empty is not the same as still looking**: - * the screen has both and must say which, since a queue called empty a second - * before its rows arrive teaches a member not to believe it. - */ -@OptIn(ExperimentalTestApi::class) -class CuratedSuggestionListScreenJvmTest { - - private val chatRoomId = "d4c3b2a1".repeat(8) - private val ada = "a".repeat(64) - private val bob = "b".repeat(64) - - private val schema = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - domain = "films.example", - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), - CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"), - CuratedField( - "watchUrl", - CuratedFieldType.Url, - isRequired = false, - label = "Watch URL", - config = CuratedFieldConfig(tag = "r", marker = "watch") - ), - CuratedField( - "review", - CuratedFieldType.LongText, - isRequired = false, - label = "Review", - config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) - ), - ), - relays = listOf("wss://relay.one.example") - ) - ) - - @Test - fun `the queue is named after the list, counted, and read from where the schema says`() = render( - state( - suggestions = listOf( - suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), - suggestion("Magic Money", 1_700_000_000, isCurated = false) - ) - ) - ) { - // The domain, as the card on the group's screen names it. - onNodeWithText("films.example").assertIsDisplayed() - onNodeWithText("2 suggestions · 1 curated").assertIsDisplayed() - onNodeWithText("Read from relay.one.example").assertIsDisplayed() - - // Newest first. - val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top - val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top - assertTrue(newest < oldest, "the queue was drawn oldest first") - } - - @Test - fun `a row shows what an entry is, not where to find it, and who suggested it`() = render( - state( - suggestions = listOf( - suggestion( - "Magic Money", - 1_700_000_000, - isCurated = false, - suggester = Profile( - publicKey = ada, - displayName = "Ada", - nostrEventId = "e".repeat(64), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ) - ) - ) - ) - ) { - // Labelled, because the screen does not know this list's fields any - // better than a stranger does; the link left to the sheet. - onNodeWithText("Year: 2018 · Type: documentary").assertIsDisplayed() - onNodeWithText("https://example.com/watch").assertDoesNotExist() - // The body, where the list has one. - onNodeWithText("Follows the early community.").assertIsDisplayed() - onNodeWithText("Suggested by Ada on", substring = true).assertIsDisplayed() - - onNodeWithText("1 suggestion").assertIsDisplayed() - onNodeWithText("Curated").assertDoesNotExist() - } - - @Test - fun `only the row the group has taken up is marked`() = render( - state( - suggestions = listOf( - suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), - suggestion("Magic Money", 1_700_000_000, isCurated = false) - ) - ) - ) { - onAllNodesWithText("Curated").assertCountEquals(1) - - val mark = onNodeWithText("Curated").getBoundsInRoot() - val curated = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot() - val waiting = onNodeWithText("Magic Money").getBoundsInRoot() - - assertTrue(mark.top < waiting.top, "the mark fell onto the row still waiting") - assertTrue(mark.bottom > curated.top, "the mark floated above the row it is about") - } - - @Test - fun `a queue still being asked for is looking, not empty`() = render( - state(suggestions = emptyList(), isAsking = true) - ) { - onNodeWithText("Asking the list's relays…").assertIsDisplayed() - onNodeWithText("Nobody has suggested anything to this list yet.").assertDoesNotExist() - onNodeWithText("Ask again").assertDoesNotExist() - } - - @Test - fun `an empty queue says so, and offers to ask again`() = render( - state(suggestions = emptyList(), isAsking = false) - ) { - onNodeWithText("Nobody has suggested anything to this list yet.").assertIsDisplayed() - onNodeWithText("Ask again").assertIsDisplayed() - onNodeWithText("Asking the list's relays…").assertDoesNotExist() - onNodeWithText("0 suggestions").assertIsDisplayed() - } - - @Test - fun `the sheet has every field the entry answered, the link included, and the way in`() = - runDesktopComposeUiTest(width = 400, height = 2000) { - var accepted = false - - setContent { - MantraTheme { - ProvideSnackbarHost { - CuratedSuggestionSheetContent( - suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = false), - schema = schema.schema, - onAccept = { accepted = true } - ) - } - } - } - - onNodeWithText("Magic Money").assertIsDisplayed() - - // As the schema labels them, in the schema's order. - onNodeWithText("Year").assertIsDisplayed() - onNodeWithText("2018").assertIsDisplayed() - onNodeWithText("Watch URL").assertIsDisplayed() - onNodeWithText("https://example.com/watch").assertIsDisplayed() - onNodeWithText("Review").assertIsDisplayed() - onNodeWithText("Follows the early community.").assertIsDisplayed() - - // The one thing to do with it, and not the event: the form shows - // the entry as fields, which is how the group will sign it. - onAllNodesWithText("\"kind\":31888", substring = true).assertCountEquals(0) - onAllNodesWithText("Copy the suggestion").assertCountEquals(0) - onNodeWithText("Accept this suggestion").assertIsDisplayed().performClick() - assertTrue(accepted, "the button did not open the form") - } - - @Test - fun `a suggestion the group already took up offers a revision, not a second acceptance`() = - runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - CuratedSuggestionSheetContent( - suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true), - schema = schema.schema, - onAccept = {} - ) - } - } - } - - onNodeWithText("Taken up by the group into the list.").assertIsDisplayed() - onNodeWithText("Revise the list's entry").assertIsDisplayed() - onAllNodesWithText("Accept this suggestion").assertCountEquals(0) - } - - private fun suggestion( - title: String, - createdAt: Long, - isCurated: Boolean, - suggester: Profile? = null - ): CuratedSuggestion { - val id = createdAt.toString().padStart(64, 'e') - val identifier = title.lowercase().replace(' ', '-') - - return CuratedSuggestion( - entry = CuratedEntry( - kind = CuratedSchemaEvent.SUGGESTION_KIND, - id = id, - pubKey = suggester?.publicKey ?: bob, - createdAt = createdAt, - identifier = identifier, - title = title, - values = mapOf( - "identifier" to listOf(identifier), - "title" to listOf(title), - "year" to listOf("2018"), - "type" to listOf("documentary"), - "watchUrl" to listOf("https://example.com/watch"), - "review" to listOf("Follows the early community."), - ) - ), - event = NostrEvent( - id = id, - pubKey = suggester?.publicKey ?: bob, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), - content = "Follows the early community.", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ), - suggester = suggester, - isCurated = isCurated - ) - } - - private fun state( - suggestions: List, - isAsking: Boolean = false - ) = CuratedSuggestionListUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = ada, - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - schema = schema, - relays = listOf("wss://relay.one.example"), - suggestions = suggestions, - isAsking = isAsking - ) - - /** - * The screen at a phone's width and a window tall enough to compose the - * whole of it. The state is passed in, so the NO_OP repositories are never - * asked for anything: `initiate` only runs for a screen that arrives loading. - */ - private fun render( - uiState: CuratedSuggestionListUIState, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - CuratedSuggestionListScreen( - activeUserPublicKey = ada, - chatRoomId = chatRoomId, - relayHint = null, - identifier = "films", - initialCuratedSuggestionListUIState = uiState, - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateBack = {}, - onNavigateToRoute = {} - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt deleted file mode 100644 index 5afdce93..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt +++ /dev/null @@ -1,267 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsOn -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.hexToNpubHrp -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel -import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * What the schema editor puts in front of a member, in the two states it opens in. - * - * **A new list starts filled in.** The two fields every list has and the group's - * own relays are on the form before anything is typed, because they are what the - * group will sign whether or not the member adds to them -- and a form that - * showed an empty list and then signed two fields would be lying about what it - * proposed. - * - * **An existing list opens as an edit.** Its identifier is locked and the form - * says why, its rules show which fields they name, and the suggesters a closed - * list admits are there to be read and removed. The rows are what a member - * checks before proposing, and each is an intention a later change to the - * `LazyColumn` could drop without anything else noticing. - * - * **The sheet offers a type its own settings.** A url field gets "https only" - * and a year field does not, because a setting the event gives no meaning to - * would be written into the schema and mean nothing. - */ -@OptIn(ExperimentalTestApi::class) -class EditGroupCuratedSchemaScreenJvmTest { - - private val chatRoomId = "d4c3b2a1".repeat(8) - - private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - - private val films = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle, with a link to watch each one.", - visibility = CuratedVisibility.Closed, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField( - name = "watchUrl", - type = CuratedFieldType.Url, - isRequired = false, - label = "Where to watch", - config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500), - ), - CuratedField( - name = "imdbUrl", - type = CuratedFieldType.Url, - isRequired = false, - label = "IMDb page", - config = CuratedFieldConfig(tag = "r", marker = "imdb", max = 500), - ), - ), - requireAny = listOf(listOf("watchUrl", "imdbUrl")), - suggesters = listOf(suggester), - relays = listOf("wss://relay.one.example"), - ) - - @Test - fun `a new list starts with the two fields every list has and the group's relays`() = render( - state(existing = null, defaultRelays = listOf("wss://relay.one.example/")) - ) { - // By their supporting line: "Identifier" is also the label over the - // identifier field above, and the row is the one that says what it is. - onNodeWithText("identifier · Token · Required · Derived").assertIsDisplayed() - onNodeWithText("title · Text · Required").assertIsDisplayed() - onNodeWithText("wss://relay.one.example/").assertIsDisplayed() - - onNodeWithText("Public").assertIsSelected() - // A public list admits anyone, so there is no list of who it admits. - onNodeWithText("Suggesters").assertDoesNotExist() - - onNodeWithText("Add field").assertIsDisplayed() - onNodeWithText("Add rule").assertIsDisplayed() - onNodeWithText("Propose schema").assertIsDisplayed() - - // Nothing locks a new identifier: the member is choosing it. - onNodeWithText("The identifier names this list in every reply", substring = true) - .assertDoesNotExist() - } - - @Test - fun `an existing list opens as an edit with its identifier locked`() = render( - state(existing = existing(films)) - ) { - onNodeWithText("films").assertIsDisplayed() - onNodeWithText("The identifier names this list in every reply", substring = true) - .assertIsDisplayed() - - onNodeWithText("Closed").assertIsSelected() - - // Every field, in the order it will be written. - val identifier = onNodeWithText("identifier · Token · Required · Derived").getBoundsInRoot().top - val watch = onNodeWithText("Where to watch").getBoundsInRoot().top - val imdb = onNodeWithText("IMDb page").getBoundsInRoot().top - assertTrue(identifier < watch && watch < imdb, "the fields were drawn out of order") - - // The rule names its fields, and the suggesters are readable as npubs. - onNodeWithText("At least one of").assertIsDisplayed() - onNodeWithText("Suggesters").assertIsDisplayed() - onNodeWithText(suggester.hexToNpubHrp()).assertIsDisplayed() - onNodeWithText("wss://relay.one.example").assertIsDisplayed() - } - - @Test - fun `a member holding no share of the key is told so and the button is inert`() = render( - state(existing = existing(films), canSign = false) - ) { - onNodeWithText("This group has no shared key, so it cannot sign a curated schema.", substring = true) - .assertIsDisplayed() - onNodeWithContentDescription("Propose schema").assertIsNotEnabled() - } - - @Test - fun `the sheet offers a type its own settings and pins a mandatory field`() { - // A url field: https is a thing it can be told, options are not. - renderSheet(field = films.fields[2], index = 2, isMandatory = false) { - onNodeWithText("Edit field").assertIsDisplayed() - onNodeWithText("Url").assertIsSelected() - onNodeWithText("https only").assertIsDisplayed() - onNodeWithText("Options").assertDoesNotExist() - onNodeWithText("Minimum value").assertDoesNotExist() - onNodeWithText("Remove field").assertIsDisplayed() - - // Changing the type changes what is offered. - onNodeWithText("One of a list").performClick() - onNodeWithText("Options").assertIsDisplayed() - onNodeWithText("https only").assertDoesNotExist() - } - - // The identifier: required whatever the switch says, and not removable. - renderSheet(field = films.fields[0], index = 0, isMandatory = true) { - onNodeWithContentDescription("An entry must have this field").assertIsOn() - onNodeWithContentDescription("An entry must have this field").assertIsNotEnabled() - onNodeWithText("Remove field").assertDoesNotExist() - } - } - - private fun existing(schema: CuratedSchema) = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, - content = schema.description, - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ), - schema = schema.normalized(), - ) - - private fun state( - existing: GroupCuratedSchema?, - defaultRelays: List = emptyList(), - canSign: Boolean = true, - ) = EditGroupCuratedSchemaUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - existing = existing, - defaultRelays = defaultRelays, - canSign = canSign, - ) - - /** - * The screen at a phone's width and a window tall enough to compose the whole - * of it, since a `LazyColumn` does not lay out what it would not show. - */ - private fun render( - uiState: EditGroupCuratedSchemaUIState, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 3000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - EditGroupCuratedSchemaScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - identifier = (uiState as? EditGroupCuratedSchemaUIState.Loaded) - ?.existing?.identifier, - initialEditGroupCuratedSchemaUIState = uiState, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = - FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {}, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } - - /** - * The sheet's content on its own: a bottom sheet is a popup with its own - * window, which a layout test cannot reach into. - */ - private fun renderSheet( - field: CuratedField, - index: Int, - isMandatory: Boolean, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - FieldEditorSheetContent( - edit = EditGroupCuratedSchemaViewModel.FieldEdit(index = index, field = field), - isMandatory = isMandatory, - onCommit = { null }, - onRemove = {} - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt deleted file mode 100644 index dc50e0b2..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedEntriesScreenJvmTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.entry -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.GroupCuratedEntriesUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The entries the group has accepted on their own screen: one card per entry, - * most recently signed first, each with its broadcast and nothing else -- - * there is no way in to add one here. - */ -@OptIn(ExperimentalTestApi::class) -class GroupCuratedEntriesScreenJvmTest { - - private val newest = entry("The Rise and Rise of Bitcoin", 1_700_000_900) - private val oldest = entry("Magic Money", 1_700_000_300, suggestedBy = null) - - @Test - fun `the entries are drawn most recently signed first, one card per entry`() = render( - state(entries = listOf(newest, oldest)) - ) { - val first = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top - val second = onNodeWithText("Magic Money").getBoundsInRoot().top - assertTrue(first < second, "the entries were drawn oldest first") - - // Which list, what the entry is -- labelled as the schema labels it -- - // and who suggested it, where somebody did. - onAllNodesWithText("In Films worth translating").assertCountEquals(2) - onAllNodesWithText("Year: 2014", substring = true).assertCountEquals(2) - onAllNodesWithText("Suggested by eebb74ab · ", substring = true).assertCountEquals(1) - // Dated like every signed statement the group makes, once per entry. - onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(2) - } - - @Test - fun `every accepted entry offers a broadcast under its own card`() { - var opened: Route? = null - - render(state(entries = listOf(newest, oldest)), onNavigateToRoute = { opened = it }) { - // The entry is the one event the group signs whose broadcast the - // queue screen depends on, since that is where the "Curated" mark is - // read back from. - val buttons = onAllNodesWithText("Broadcast") - buttons.assertCountEquals(2) - - val firstCard = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top - val secondCard = onNodeWithText("Magic Money").getBoundsInRoot().top - val first = buttons[0].getBoundsInRoot().top - val second = buttons[1].getBoundsInRoot().top - - assertTrue(firstCard < first && first < secondCard, "the first entry's broadcast is not under its card") - assertTrue(secondCard < second, "the second entry's broadcast is not under its card") - - buttons[0].performClick() - } - - assertEquals( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - signedEventId = newest.signedEvent.id - ), - opened - ) - } - - @Test - fun `a group that has accepted nothing says so, and offers no way to add one here`() = render( - state(entries = emptyList()) - ) { - onNodeWithText("This group hasn't accepted anything into a list yet.", substring = true) - .assertIsDisplayed() - - // An entry is accepted from the queue on its schema's card, on the - // schemas screen, so this screen has no button of its own. - onAllNodesWithText("Add entry").assertCountEquals(0) - onAllNodesWithText("Broadcast").assertCountEquals(0) - } - - private fun state(entries: List) = GroupCuratedEntriesUIState.Loaded( - localChatRoom = GroupSignedWorkFixtures.localChatRoom(), - groupCuratedEntries = entries - ) - - private fun render( - uiState: GroupCuratedEntriesUIState, - onNavigateToRoute: (Route) -> Unit = {}, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - GroupCuratedEntriesScreen( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - initialGroupCuratedEntriesUIState = uiState, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = onNavigateToRoute, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt deleted file mode 100644 index 2bb92775..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupCuratedSchemasScreenJvmTest.kt +++ /dev/null @@ -1,231 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.schema -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.GroupCuratedSchemasUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * The schemas of the lists the group curates on their own screen: one card per - * list, most recently signed first, each with its queue and its broadcast, and - * two ways into the editor -- the card for a revision, the button for a new - * list -- for a member holding a share. - */ -@OptIn(ExperimentalTestApi::class) -class GroupCuratedSchemasScreenJvmTest { - - private val films = schema("films", "Films worth translating", 1_700_000_900) - private val books = schema("books", "Books worth translating", 1_700_000_000) - - @Test - fun `the schemas are drawn most recently signed first, one card per list`() = render( - state(schemas = listOf(films, books)) - ) { - val newest = onNodeWithText("Films worth translating").getBoundsInRoot().top - val oldest = onNodeWithText("Books worth translating").getBoundsInRoot().top - assertTrue(newest < oldest, "the schemas were drawn oldest first") - - // What an entry asks for, and who may suggest one. - onAllNodesWithText("Fields: Identifier, Title", substring = true).assertCountEquals(2) - onAllNodesWithText("Public · ", substring = true).assertCountEquals(2) - onNodeWithText("Add schema").assertIsDisplayed() - } - - @Test - fun `every schema offers its own queue, under its own card`() { - var opened: Route? = null - - render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) { - // One per list, because each list has its own queue -- and under the - // card it belongs to rather than once for the screen, since a button - // between two cards would say nothing about which it opened. - val buttons = onAllNodesWithText("View suggestions") - buttons.assertCountEquals(2) - - val filmsCard = onNodeWithText("Films worth translating").getBoundsInRoot().top - val booksCard = onNodeWithText("Books worth translating").getBoundsInRoot().top - val first = buttons[0].getBoundsInRoot().top - val second = buttons[1].getBoundsInRoot().top - - assertTrue(filmsCard < first && first < booksCard, "the first queue button is not under its card") - assertTrue(booksCard < second, "the second queue button is not under its card") - - buttons[1].performClick() - } - - assertEquals( - CuratedSuggestionListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - identifier = "books" - ), - opened - ) - } - - @Test - fun `every schema offers a broadcast, beside its queue`() { - var opened: Route? = null - - render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) { - val buttons = onAllNodesWithText("Broadcast") - buttons.assertCountEquals(2) - - val filmsCard = onNodeWithText("Films worth translating").getBoundsInRoot().top - val booksCard = onNodeWithText("Books worth translating").getBoundsInRoot().top - assertTrue(filmsCard < buttons[0].getBoundsInRoot().top, "the first broadcast is not under its card") - assertTrue(booksCard < buttons[1].getBoundsInRoot().top, "the second broadcast is not under its card") - - buttons[0].performClick() - } - - assertEquals( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - signedEventId = films.signedEvent.id - ), - opened - ) - } - - @Test - fun `a card opens the editor on its own schema, for a member holding a share of the key`() { - var opened: Route? = null - - render(state(schemas = listOf(films, books)), onNavigateToRoute = { opened = it }) { - onNodeWithText("Books worth translating").performClick() - } - - assertEquals( - EditGroupCuratedSchemaRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - identifier = "books" - ), - opened - ) - } - - @Test - fun `the button opens the editor on no schema at all`() { - var opened: Route? = null - - render(state(schemas = listOf(films)), onNavigateToRoute = { opened = it }) { - onNodeWithText("Add schema").performClick() - } - - assertEquals( - EditGroupCuratedSchemaRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - identifier = null - ), - opened - ) - } - - @Test - fun `a member holding no share of the key reads the schemas and is offered no editor`() { - var opened: Route? = null - - render(state(schemas = listOf(films), canSign = false), onNavigateToRoute = { opened = it }) { - // A schema exists to be read by people who were never in the room, so - // a member without a share reads it like anybody else and simply - // cannot propose a revision -- neither a new one nor an edit. - onNodeWithText("Films worth translating").assertIsDisplayed() - onNodeWithText("Add schema").assertDoesNotExist() - onNodeWithContentDescription("Edit schema").assertDoesNotExist() - - // The queue is the one thing on the screen the group did not write, - // and reading it takes no share of the key. - onNodeWithText("View suggestions").assertIsDisplayed() - - // A card that cannot be opened goes nowhere when tapped. - onNodeWithText("Films worth translating").performClick() - } - - assertNull(opened, "a card with no editor behind it opened something") - } - - @Test - fun `a group that curates nothing says so, and offers to add a schema`() { - var opened: Route? = null - - render(state(schemas = emptyList()), onNavigateToRoute = { opened = it }) { - onNodeWithText("This group hasn't published a curated schema yet.", substring = true) - .assertIsDisplayed() - onAllNodesWithText("View suggestions").assertCountEquals(0) - onNodeWithText("Add schema").assertIsDisplayed().performClick() - } - - assertEquals( - EditGroupCuratedSchemaRoute(activeUserPublicKey, chatRoomId, relayHint = null, identifier = null), - opened - ) - } - - private fun state( - schemas: List, - canSign: Boolean = true - ) = GroupCuratedSchemasUIState.Loaded( - localChatRoom = GroupSignedWorkFixtures.localChatRoom(), - groupCuratedSchemas = schemas, - canSign = canSign - ) - - private fun render( - uiState: GroupCuratedSchemasUIState, - onNavigateToRoute: (Route) -> Unit = {}, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - GroupCuratedSchemasScreen( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - initialGroupCuratedSchemasUIState = uiState, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = onNavigateToRoute, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt deleted file mode 100644 index 8f94dd16..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileScreenJvmTest.kt +++ /dev/null @@ -1,153 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.GroupNostrProfileUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The group's nostr profile on its own screen: what is drawn, what the - * broadcast under it sends, and who is offered the editor. - */ -@OptIn(ExperimentalTestApi::class) -class GroupNostrProfileScreenJvmTest { - - @Test - fun `the profile is drawn whole, with its broadcast under it`() { - var opened: Route? = null - - render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) { - onNodeWithText("Translation collective").assertIsDisplayed() - onNodeWithText("group@example.com").assertIsDisplayed() - onNodeWithText("A group translating hard books into Sesotho.").assertIsDisplayed() - onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(1) - - // One, under the card it sends, and it sends the profile's event. - val buttons = onAllNodesWithText("Broadcast") - buttons.assertCountEquals(1) - val card = onNodeWithText("group@example.com").getBoundsInRoot().top - assertTrue(card < buttons[0].getBoundsInRoot().top, "the broadcast is not under the card") - - buttons[0].performClick() - } - - assertEquals( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - signedEventId = profile.signedEvent.id - ), - opened - ) - } - - @Test - fun `a member holding a share of the key is offered the editor`() { - var opened: Route? = null - - render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) { - onNodeWithText("Edit Nostr profile").assertIsDisplayed().performClick() - } - - assertEquals( - EditGroupNostrProfileRoute(activeUserPublicKey, chatRoomId, relayHint = null), - opened - ) - } - - @Test - fun `a member holding no share of the key reads the profile and is offered no edit`() = render( - state(groupNostrProfile = profile, canSign = false) - ) { - // The profile is worth reading whoever is looking; proposing one needs a - // share, and `proposeSigningBatch` throws without one. The broadcast - // stays: the signature is the group's whoever repeats it. - onNodeWithText("Translation collective").assertIsDisplayed() - onNodeWithText("Edit Nostr profile").assertDoesNotExist() - onAllNodesWithText("Broadcast").assertCountEquals(1) - } - - @Test - fun `a group that has said nothing says so, and offers to say something`() = render( - state(groupNostrProfile = null) - ) { - onNodeWithText("This group hasn't said anything about itself on Nostr yet.") - .assertIsDisplayed() - onNodeWithText("Edit Nostr profile").assertIsDisplayed() - - // Nothing signed, so nothing to send. - onAllNodesWithText("Broadcast").assertCountEquals(0) - } - - @Test - fun `a group that has said nothing offers no editor to a member holding no share`() = render( - state(groupNostrProfile = null, canSign = false) - ) { - onNodeWithText("This group hasn't said anything about itself on Nostr yet.") - .assertIsDisplayed() - onNodeWithText("Edit Nostr profile").assertDoesNotExist() - } - - private fun state( - groupNostrProfile: GroupNostrProfile?, - canSign: Boolean = true - ) = GroupNostrProfileUIState.Loaded( - localChatRoom = GroupSignedWorkFixtures.localChatRoom(), - groupNostrProfile = groupNostrProfile, - canSign = canSign - ) - - /** - * The screen at a phone's width. The state is passed in, so the NO_OP - * repository is never asked for anything: `initiate` only runs for a screen - * that arrives loading. - */ - private fun render( - uiState: GroupNostrProfileUIState, - onNavigateToRoute: (Route) -> Unit = {}, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - GroupNostrProfileScreen( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - initialGroupNostrProfileUIState = uiState, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = onNavigateToRoute, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt deleted file mode 100644 index 15a1af7a..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupPostsScreenJvmTest.kt +++ /dev/null @@ -1,167 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.post -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile -import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.GroupPostsUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The group's posts on their own screen: newest first and whole, under the - * group's name, each with its broadcast, and the composer for a member holding - * a share. - */ -@OptIn(ExperimentalTestApi::class) -class GroupPostsScreenJvmTest { - - @Test - fun `the posts are drawn newest first, under the group's name`() = render( - state(posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000))) - ) { - // Newest first, and whole rather than previewed -- a post is the one thing - // the group signs that exists to be read. - val newest = onNodeWithText("The second thing").getBoundsInRoot().top - val oldest = onNodeWithText("The first thing").getBoundsInRoot().top - assertTrue(newest < oldest, "the posts were drawn oldest first") - - // Under the group's name, not the member's. The whole point of signing a - // post as the group is that no member can be blamed for it. Once per - // post: there is no profile card on this screen to add a third. - onAllNodesWithText("Translation collective").assertCountEquals(2) - onNodeWithText("Add post").assertIsDisplayed() - } - - @Test - fun `a group that has not described itself bylines its posts with the key`() = render( - state(posts = listOf(post("Something the group said", 1_700_000_000)), groupNostrProfile = null) - ) { - onNodeWithText("Something the group said").assertIsDisplayed() - onNodeWithText(chatRoomId.take(16).chunked(8).joinToString(" ")).assertIsDisplayed() - } - - @Test - fun `every post offers a broadcast under its card`() { - var opened: Route? = null - - render( - state(posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000))), - onNavigateToRoute = { opened = it } - ) { - val buttons = onAllNodesWithText("Broadcast") - buttons.assertCountEquals(2) - - val newest = onNodeWithText("The second thing").getBoundsInRoot().top - val oldest = onNodeWithText("The first thing").getBoundsInRoot().top - val first = buttons[0].getBoundsInRoot().top - val second = buttons[1].getBoundsInRoot().top - - assertTrue(newest < first && first < oldest, "the first post's broadcast is not under its card") - assertTrue(oldest < second, "the second post's broadcast is not under its card") - - buttons[1].performClick() - } - - assertEquals( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - signedEventId = post("The first thing", 1_700_000_000).signedEvent.id - ), - opened - ) - } - - @Test - fun `a group that has posted nothing says so, and offers to post`() { - var opened: Route? = null - - render(state(posts = emptyList()), onNavigateToRoute = { opened = it }) { - onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed() - onAllNodesWithText("Broadcast").assertCountEquals(0) - onNodeWithText("Add post").assertIsDisplayed().performClick() - } - - assertEquals(AddGroupPostRoute(activeUserPublicKey, chatRoomId, relayHint = null), opened) - } - - @Test - fun `a member holding no share of the key reads the posts and is offered no composer`() = render( - state(posts = listOf(post("Something the group said", 1_700_000_000)), canSign = false) - ) { - // A post is the one thing here that is meant to be read by people who - // were not in the room, so a member without a share reads it like - // anybody else and simply cannot propose the next one. - onNodeWithText("Something the group said").assertIsDisplayed() - onNodeWithText("Add post").assertDoesNotExist() - onAllNodesWithText("Broadcast").assertCountEquals(1) - } - - @Test - fun `a group that has posted nothing offers no composer to a member holding no share`() = render( - state(posts = emptyList(), canSign = false) - ) { - onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed() - onNodeWithText("Add post").assertDoesNotExist() - } - - private fun state( - posts: List, - groupNostrProfile: GroupNostrProfile? = profile, - canSign: Boolean = true - ) = GroupPostsUIState.Loaded( - localChatRoom = GroupSignedWorkFixtures.localChatRoom(), - groupPosts = posts, - groupNostrProfile = groupNostrProfile, - canSign = canSign - ) - - private fun render( - uiState: GroupPostsUIState, - onNavigateToRoute: (Route) -> Unit = {}, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - GroupPostsScreen( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - initialGroupPostsUIState = uiState, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = onNavigateToRoute, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt deleted file mode 100644 index 90b42888..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupRelaysScreenJvmTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.relaySignedEvent -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.signedRelayLists -import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute -import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.GroupRelaysUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The group's relay lists on their own screen: a row per set whatever the - * group has said, a broadcast under each list it has agreed, and the editor - * for a member holding a share. - */ -@OptIn(ExperimentalTestApi::class) -class GroupRelaysScreenJvmTest { - - @Test - fun `every set gets a row, and the two absences read differently`() = render(state()) { - onNodeWithText("General").assertIsDisplayed() - onNodeWithText("relay.one.example").assertIsDisplayed() - onNodeWithText("Messages").assertIsDisplayed() - onNodeWithText("Search").assertIsDisplayed() - onNodeWithText("Blocked").assertIsDisplayed() - // Two of them, because Messages and Search are both unagreed -- and the - // count is the assertion: a row per set, not a row per set the group has - // got round to. The blocked list was agreed empty, which is a decision - // and reads as one. - onAllNodesWithText("Not set yet").assertCountEquals(2) - onNodeWithText("No relays in this list").assertIsDisplayed() - } - - @Test - fun `every agreed list offers a broadcast under its row, the withdrawn one included`() { - var opened: Route? = null - - render(state(), onNavigateToRoute = { opened = it }) { - // The two the group has agreed -- the withdrawn one included, since a - // withdrawal is a statement relays still holding the old list need to - // hear -- and not the two it has never agreed, which are not events. - val buttons = onAllNodesWithText("Broadcast") - buttons.assertCountEquals(2) - - val general = onNodeWithText("General").getBoundsInRoot().top - val blocked = onNodeWithText("Blocked").getBoundsInRoot().top - val first = buttons[0].getBoundsInRoot().top - val second = buttons[1].getBoundsInRoot().top - - assertTrue(general < first && first < blocked, "the general list's broadcast is not under its row") - assertTrue(blocked < second, "the blocked list's broadcast is not under its row") - - buttons[1].performClick() - } - - assertEquals( - BroadcastGroupSignedEventRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - signedEventId = relaySignedEvent(GroupRelaySet.Blocked).id - ), - opened - ) - } - - @Test - fun `a member holding a share of the key is offered the editor`() { - var opened: Route? = null - - render(state(), onNavigateToRoute = { opened = it }) { - onNodeWithText("Edit relays").assertIsDisplayed().performClick() - } - - assertEquals(EditGroupRelaysRoute(activeUserPublicKey, chatRoomId, relayHint = null), opened) - } - - @Test - fun `a member holding no share of the key reads the relays and is offered no edit`() = render( - state(canSign = false) - ) { - onNodeWithText("General").assertIsDisplayed() - onNodeWithText("relay.one.example").assertIsDisplayed() - onNodeWithText("Edit relays").assertDoesNotExist() - onAllNodesWithText("Broadcast").assertCountEquals(2) - } - - private fun state(canSign: Boolean = true) = GroupRelaysUIState.Loaded( - localChatRoom = GroupSignedWorkFixtures.localChatRoom(), - groupRelayLists = signedRelayLists(), - canSign = canSign - ) - - private fun render( - uiState: GroupRelaysUIState, - onNavigateToRoute: (Route) -> Unit = {}, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 2000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - GroupRelaysScreen( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - initialGroupRelaysUIState = uiState, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateToRoute = onNavigateToRoute, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt deleted file mode 100644 index fae7c63a..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt +++ /dev/null @@ -1,212 +0,0 @@ -package press.mantra.compose.ui.composable - -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupKeyState -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSuggestionRef -import press.mantra.compose.nostr.curated.CuratedVisibility -import kotlin.time.Instant - -/** - * One group's signed work, for the six screens that show it: the group's own, - * where a row names each kind, and the five behind those rows. - * - * Everything here is built rather than read out of a signed event. The reading - * checks a real signature, and these tests are about the screens rather than - * about that -- so nothing here verifies, and none of the keys derive. - */ -internal object GroupSignedWorkFixtures { - - val chatRoomId = "d4c3b2a1".repeat(8) - - val activeUserPublicKey = "a".repeat(64) - - val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - - /** - * The room, as this device names it. - * - * The subject is deliberately not the profile's name: the room's subject is - * this device's name for it and the profile is the group's own, and a test - * that used one string for both could not tell them apart. - */ - fun localChatRoom(mlsGroupState: String? = "state") = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = activeUserPublicKey, - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = mlsGroupState - ) - ) - - /** - * A key state, so the signing key row the identity block sits under is on - * the group's screen to be measured against. Not a real key: nothing here - * derives. - */ - val groupKeyState = GroupKeyState( - chatRoomId = chatRoomId, - dkgSessionId = "c".repeat(64), - thresholdPublicKey = "02".padEnd(66, 'a'), - derivationPath = "m/9420/0/0", - announcedBy = chatRoomId, - announcedAt = Instant.fromEpochSeconds(1_700_000_000) - ) - - val profile = GroupNostrProfile( - signedEvent = GroupSignedEvent( - id = "e".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = MetadataEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ), - metadata = UserMetadata().apply { - displayName = "Translation collective" - about = "A group translating hard books into Sesotho." - nip05 = "group@example.com" - } - ) - - /** - * Two agreed sets and two absences, which is the only fixture that exercises - * the three things a relay row can say: a list, "not set yet", and "no - * relays". - */ - fun signedRelayLists(): List = GroupRelaySet.entries.map { set -> - when (set) { - GroupRelaySet.General -> GroupRelayList( - set = set, - signedEvent = relaySignedEvent(set), - relays = listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example"))) - ) - // Agreed and empty: the group withdrew its list rather than never - // having had one. - GroupRelaySet.Blocked -> GroupRelayList( - set = set, - signedEvent = relaySignedEvent(set), - relays = emptyList() - ) - else -> GroupRelayList(set = set, signedEvent = null, relays = emptyList()) - } - } - - fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent( - id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = set.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000) - ) - - fun post(content: String, createdAt: Long) = GroupPost( - signedEvent = GroupSignedEvent( - id = createdAt.toString().padStart(64, 'b'), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = TextNoteEvent.KIND, - tags = emptyArray(), - content = content, - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ) - ) - - /** A list the group curates, as the schema it signed for it. */ - fun schema(identifier: String, name: String, createdAt: Long) = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = createdAt.toString().padStart(64, 'c'), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ), - schema = CuratedSchema( - identifier = identifier, - title = "$name suggestion", - name = name, - description = "Things the group would translate, with a link to each.", - visibility = CuratedVisibility.Public, - fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), - relays = listOf("wss://relay.one.example") - ) - ) - - /** - * An entry the group accepted into the films list. The list's schema has a - * year, so the card has a glance to give. - */ - fun entry( - title: String, - createdAt: Long, - suggestedBy: String? = suggester - ): GroupCuratedEntry { - val list = schema("films", "Films worth translating", 1_700_000_000).let { films -> - films.copy( - schema = films.schema.copy( - fields = films.schema.fields + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year") - ) - ) - } - val identifier = title.lowercase().replace(' ', '-') - - return GroupCuratedEntry( - signedEvent = GroupSignedEvent( - id = createdAt.toString().padStart(64, 'd'), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedEntryEvent.CANONICAL_KIND, - tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt) - ), - list = list, - entry = CuratedEntry( - kind = CuratedEntryEvent.CANONICAL_KIND, - id = createdAt.toString().padStart(64, 'd'), - pubKey = chatRoomId, - createdAt = createdAt, - identifier = identifier, - title = title, - values = mapOf( - "identifier" to listOf(identifier), - "title" to listOf(title), - "year" to listOf("2014"), - ), - source = suggestedBy?.let { - CuratedSuggestionRef(id = "a".repeat(64), coordinate = "31888:$it:$identifier", pubKey = it) - } - ) - ) - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt deleted file mode 100644 index 76368e53..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkRowsJvmTest.kt +++ /dev/null @@ -1,282 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.getBoundsInRoot -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runDesktopComposeUiTest -import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import press.mantra.compose.nostr.GroupCuratedEntry -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupNostrProfile -import press.mantra.compose.nostr.GroupPost -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.MantraRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.entry -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.post -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.profile -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.schema -import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.signedRelayLists -import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedEntriesRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupCuratedSchemasRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupNostrProfileRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupPostsRoute -import press.mantra.compose.ui.composable.navigation.routes.GroupRelaysRoute -import press.mantra.compose.ui.composable.navigation.routes.Route -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.ChatRoomDetailUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The five rows on the group's screen that stand for its signed work, and what - * each one opens. - * - * The rows' contents need little testing -- a name and a count in a `ListItem` - * -- but three things about them do, and none is settled by reading the screen - * top to bottom. - * - * **Their place in the order.** They go under what the room *is* -- the key it - * signs as and whose child it is -- and above what the room has *made*, because - * the group's account of itself is part of its identity rather than part of its - * work; and among themselves they read as a chain, from who the group says it is - * to what it has decided. That ordering is an intention a later `item {}` in - * the wrong place would silently undo, and the screen is long enough that - * nobody would notice. - * - * **What they open.** A row is a promise about the screen behind it, and five - * rows that look alike are five chances to wire one to the wrong route. - * - * **Their absence in a NIP-17 room.** A NIP-17 room's id is a conversation, not - * a key it can sign as, so it has no nostr identity and never will. A row there - * would promise something that is not coming, which is worse than saying - * nothing. - */ -@OptIn(ExperimentalTestApi::class) -class GroupSignedWorkRowsJvmTest { - - @Test - fun `the rows sit under the signing key and above the group's work, in the order the block reads`() = - render(state(groupNostrProfile = profile)) { - val signingKey = onNodeWithText("Signing key").getBoundsInRoot().top - val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top - val relays = onNodeWithText("Relays").getBoundsInRoot().top - val posts = onNodeWithText("Posts").getBoundsInRoot().top - val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top - val entries = onNodeWithText("Curated entries").getBoundsInRoot().top - val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top - - assertTrue(signingKey < nostrProfile, "the nostr profile row climbed above the signing key row") - assertTrue(nostrProfile < relays, "the relays row climbed above the profile") - assertTrue(relays < posts, "the posts row climbed above the relays") - assertTrue(posts < schemas, "the curated schemas row climbed above the posts") - assertTrue(schemas < entries, "the curated entries row climbed above the schemas") - assertTrue(entries < subgroups, "the identity block fell below the group's work") - } - - @Test - fun `each row says what its screen holds`() = render( - state( - groupNostrProfile = profile, - relayLists = signedRelayLists(), - posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000)), - schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)) - ) - ) { - // The group's own name and address, not the room's subject: the row is - // about what the rest of nostr sees. - onNodeWithText("Translation collective · group@example.com").assertIsDisplayed() - // Counted against the four sets, the withdrawn one included: an agreed - // empty list is a decision, and the row counts decisions. - onNodeWithText("2 of 4 lists agreed").assertIsDisplayed() - onNodeWithText("2 posts").assertIsDisplayed() - onNodeWithText("1 schema").assertIsDisplayed() - onNodeWithText("No entries yet").assertIsDisplayed() - } - - @Test - fun `a group that has signed nothing has rows that say so`() = render( - state(groupNostrProfile = null) - ) { - onNodeWithText("Not described yet").assertIsDisplayed() - onNodeWithText("0 of 4 lists agreed").assertIsDisplayed() - onNodeWithText("No posts yet").assertIsDisplayed() - onNodeWithText("No schemas yet").assertIsDisplayed() - onNodeWithText("No entries yet").assertIsDisplayed() - } - - @Test - fun `a profile with no name is not named after the key`() = render( - state( - groupNostrProfile = GroupNostrProfile( - signedEvent = profile.signedEvent, - metadata = UserMetadata() - ) - ) - ) { - // The signing key row already shows the key, and showing it twice would - // say the group has a name when it has not. - onNodeWithText("No name on this profile").assertIsDisplayed() - } - - @Test - fun `each row opens the screen for its section`() { - val expected = mapOf( - "Nostr profile" to GroupNostrProfileRoute(activeUserPublicKey, chatRoomId, relayHint = null), - "Relays" to GroupRelaysRoute(activeUserPublicKey, chatRoomId, relayHint = null), - "Posts" to GroupPostsRoute(activeUserPublicKey, chatRoomId, relayHint = null), - "Curated schemas" to GroupCuratedSchemasRoute(activeUserPublicKey, chatRoomId, relayHint = null), - "Curated entries" to GroupCuratedEntriesRoute(activeUserPublicKey, chatRoomId, relayHint = null), - ) - - expected.forEach { (row, route) -> - var opened: Route? = null - - render(state(groupNostrProfile = profile), onNavigateToRoute = { opened = it }) { - onNodeWithText(row).performClick() - } - - assertEquals(route, opened, "the $row row opened the wrong screen") - } - } - - @Test - fun `the contents stay off the group's screen`() = render( - state( - groupNostrProfile = profile, - relayLists = signedRelayLists(), - posts = listOf(post("Something the group said", 1_700_000_000)), - schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), - entries = listOf(entry("The Rise and Rise of Bitcoin", 1_700_000_900)) - ) - ) { - // The rows summarise; the cards, their broadcasts and the gated ways in - // are on the screens behind them. A card that stayed here would be the - // old section under a new name. - onNodeWithText("A group translating hard books into Sesotho.").assertDoesNotExist() - onNodeWithText("relay.one.example").assertDoesNotExist() - onNodeWithText("Something the group said").assertDoesNotExist() - onNodeWithText("The Rise and Rise of Bitcoin").assertDoesNotExist() - onAllNodesWithText("Broadcast").assertCountEquals(0) - onNodeWithText("Edit Nostr profile").assertDoesNotExist() - onNodeWithText("Edit relays").assertDoesNotExist() - onNodeWithText("Add post").assertDoesNotExist() - onNodeWithText("Add schema").assertDoesNotExist() - onNodeWithText("View suggestions").assertDoesNotExist() - } - - @Test - fun `a member holding a share of the key may propose any event the rows can open`() = render( - state(groupNostrProfile = profile) - ) { - // Last in the identity block: after the rows and before the divider, - // because it is about the whole block rather than any one row of it. - val entries = onNodeWithText("Curated entries").getBoundsInRoot().top - val propose = onNodeWithText("Propose event").getBoundsInRoot().top - val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top - - assertTrue(entries < propose, "the paste entry climbed above the rows") - assertTrue(propose < subgroups, "the paste entry fell out of the identity block") - } - - @Test - fun `a member holding no share of the key is offered no paste`() = render( - state(groupNostrProfile = profile, canEditNostrProfile = false) - ) { - // The same gate as every way in behind the rows: proposing is a signature - // by the group, and `proposeSigning` throws for a device holding no share. - // The rows themselves stay, because reading takes no share. - onNodeWithText("Propose event").assertDoesNotExist() - onNodeWithText("Nostr profile").assertIsDisplayed() - onNodeWithText("Curated entries").assertIsDisplayed() - } - - @Test - fun `a NIP-17 room has no nostr identity and so no rows`() = render( - state(groupNostrProfile = null, mlsGroupState = null, canEditNostrProfile = false) - ) { - onNodeWithText("Nostr profile").assertDoesNotExist() - onNodeWithText("Not described yet").assertDoesNotExist() - - // The other four go with it: all five describe a nostr identity this - // room does not have, and so does the paste. - onNodeWithText("Relays").assertDoesNotExist() - onNodeWithText("Posts").assertDoesNotExist() - onNodeWithText("Curated schemas").assertDoesNotExist() - onNodeWithText("Curated entries").assertDoesNotExist() - onNodeWithText("Propose event").assertDoesNotExist() - - // The rest of the screen is untouched, so the rows' absence is an - // absence rather than a screen that failed to draw. - onNodeWithText("Subgroups").assertIsDisplayed() - } - - private fun state( - groupNostrProfile: GroupNostrProfile?, - relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), - posts: List = emptyList(), - schemas: List = emptyList(), - entries: List = emptyList(), - canEditNostrProfile: Boolean = true, - mlsGroupState: String? = "state" - ) = ChatRoomDetailUIState.Loaded( - localChatRoom = GroupSignedWorkFixtures.localChatRoom(mlsGroupState), - groupKeyState = GroupSignedWorkFixtures.groupKeyState, - groupNostrProfile = groupNostrProfile, - groupRelayLists = relayLists, - groupPosts = posts, - groupCuratedSchemas = schemas, - groupCuratedEntries = entries, - canEditNostrProfile = canEditNostrProfile - ) - - /** - * The screen at a phone's width and a window tall enough to compose the whole - * of it, since a `LazyColumn` does not lay out what it would not show. - * - * The state is passed in rather than loaded, so the NO_OP repositories are - * never asked for anything: `initiateChatRoomDetail` only runs for a screen - * that arrives in [ChatRoomDetailUIState.Loading]. - */ - private fun render( - uiState: ChatRoomDetailUIState, - onNavigateToRoute: (Route) -> Unit = {}, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 4000) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - ChatRoomDetailScreen( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = null, - initialChatRoomDetailUIState = uiState, - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, - onNavigateToRoute = onNavigateToRoute, - onPopBackToRoute = {}, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt deleted file mode 100644 index 19d7afd4..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -package press.mantra.compose.ui.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.ComposeUiTest -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.runDesktopComposeUiTest -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost -import press.mantra.compose.ui.theme.MantraTheme -import press.mantra.compose.ui.view.state.ProposeGroupEventUIState -import kotlin.test.Test - -/** - * What the paste screen says back as an event is typed into it. - * - * The screen's one job beyond a text field is to tell a member, before a quorum - * is asked, what the group would be signing or why it will not be asked. Both - * are read live off the paste, so both are tested by typing: a schema reads back - * as the signing screen would describe it, junk is named as junk, and a kind - * with no section to land in is refused with the kinds that have one. - */ -@OptIn(ExperimentalTestApi::class) -class ProposeGroupEventScreenJvmTest { - - private val chatRoomId = "d4c3b2a1".repeat(8) - - private val schema = """{"kind":31889,"created_at":1735689600,"tags":[["d","bitcoin.mov"],""" + - """["title","bitcoin.mov suggestion"],["name","bitcoin.mov"],""" + - """["description","Fields for a bitcoin.mov entry."],["visibility","public"],""" + - """["field","title","text","required","","Title","{}"]],"content":"Fields for a bitcoin.mov entry."}""" - - @Test - fun `a pasted schema is read back as the signing screen would say it`() = render(state()) { - onNodeWithText("Unsigned event").performTextInput(schema) - - onNodeWithText("What the group would sign").assertIsDisplayed() - onNodeWithText("Kind 31889 · Curated schema").assertIsDisplayed() - onNodeWithText("bitcoin.mov · public · 1 field").assertIsDisplayed() - } - - @Test - fun `junk is named as junk`() = - render(state()) { - onNodeWithText("Unsigned event").performTextInput("this is not an event") - onNodeWithText("That isn't JSON.").assertIsDisplayed() - onNodeWithText("What the group would sign").assertDoesNotExist() - } - - @Test - fun `a kind the screen cannot file is refused with the kinds it can`() = render(state()) { - onNodeWithText("Unsigned event").performTextInput("""{"kind": 30023, "content": "An article"}""") - - onNodeWithText("Kind 30023 has nowhere to go on the group's screen.", substring = true) - .assertIsDisplayed() - onNodeWithText("0, 1, 10002, 10006, 10007, 10050, 31889", substring = true).assertIsDisplayed() - } - - @Test - fun `a member holding no share of the key is told so and the button is inert`() = render( - state(canSign = false) - ) { - onNodeWithText("This group has no shared key, so it cannot sign an event.", substring = true) - .assertIsDisplayed() - onNodeWithContentDescription("Propose event").assertIsNotEnabled() - } - - private fun state(canSign: Boolean = true) = ProposeGroupEventUIState.Loaded( - localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = "A group translating hard books.", - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ), - canSign = canSign, - ) - - private fun render( - uiState: ProposeGroupEventUIState, - assertions: ComposeUiTest.() -> Unit - ) = runDesktopComposeUiTest(width = 400, height = 1200) { - setContent { - MantraTheme { - ProvideSnackbarHost { - Box(modifier = Modifier.fillMaxSize()) { - ProposeGroupEventScreen( - activeUserPublicKey = "a".repeat(64), - chatRoomId = chatRoomId, - relayHint = null, - initialProposeGroupEventUIState = uiState, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = - FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - onNavigateToRouteAndPopUpInclusive = {}, - onNavigateBack = {}, - ) - } - } - } - } - - assertions() - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt deleted file mode 100644 index 97f4d2cc..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt +++ /dev/null @@ -1,412 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Kind -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withTimeout -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.FrostSigningSession -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.database.model.intermdiate.LocalNostrEvent -import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedEntryProblem -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * The decisions the accept form makes that nothing else can check. - * - * **What is opened and what is kept.** The form asks for what the schema's - * form asks for, and keeps the `d` and the derived fields as they were - * suggested; getting that wrong in one direction lets a member invent a - * hashtag the app was supposed to derive, and in the other loses the - * coordinate that makes a re-curation a revision. - * - * **What the button refuses.** An entry the queue would not count is refused - * with the field named, before a quorum is asked for it -- and the entry it - * does propose is the one the queue screen will read back, source and all. - * - * **What the loader finds.** The suggestion is looked up by the queue's own - * reading, so what is accepted is what was shown. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class AcceptCuratedSuggestionViewModelJvmTest { - private val chatRoomId = "d4c3b2a1".repeat(8) - private val ada = "a".repeat(64) - private val bob = "b".repeat(64) - - private val schema = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), - CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))), - CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)), - CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, label = "IMDb URL", config = CuratedFieldConfig(tag = "r", marker = "imdb")), - CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)), - CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)), - ), - requireAny = listOf(listOf("watchUrl", "imdbUrl")), - relays = listOf("wss://relay.one.example"), - ).normalized(), - ) - - private val localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = ada, - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ) - - /** *Magic Money* as Bob suggested it: two watch links, and hashtags his client derived. */ - private val suggestedEvent = NostrEvent( - id = "1".repeat(64), - pubKey = bob, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = arrayOf( - arrayOf("d", "magic-money"), - arrayOf("title", "Magic Money"), - arrayOf("year", "2018"), - arrayOf("type", "documentary"), - arrayOf("r", "https://example.com/watch", "watch"), - arrayOf("r", "https://mirror.example/watch", "watch"), - arrayOf("t", "bitcoin"), - arrayOf("t", "documentary"), - arrayOf("a", schema.coordinate(), "", "root"), - ), - content = "Follows the early community.", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ) - - private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf( - schema, - listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null)) - ).single() - - private fun loaded(canSign: Boolean = true) = AcceptCuratedSuggestionUIState.Loaded( - localChatRoom = localChatRoom, - schema = schema, - suggestion = suggestion, - canSign = canSign, - ) - - /** What was asked of the signing repository, and the session it answers with. */ - private class RecordingSigning(private val session: FrostSigningSession?) : - FrostSigningRepository by FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY { - var kind: Kind? = null - var tags: Array>? = null - var content: String? = null - - override suspend fun canSign(chatRoomId: String): Boolean = true - - override suspend fun proposeSigning( - localChatRoom: LocalChatRoom, - userPublicKey: HexKey, - kind: Kind, - tags: Array>, - content: String - ): FrostSigningSession? { - this.kind = kind - this.tags = tags - this.content = content - return session - } - } - - private fun session() = FrostSigningSession( - id = "session-1", - chatRoomId = chatRoomId, - coordinatorPublicKey = ada, - userPublicKey = ada, - dkgSessionId = "dkg-1", - threshold = 2, - participantCount = 3, - signerId = 1, - ) - - private fun viewModel( - signing: FrostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository: ChatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - suggestionId: String = suggestion.id, - ) = AcceptCuratedSuggestionViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = ada, - relayHint = null, - identifier = "films", - suggestionId = suggestionId, - initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loading, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - frostSigningRepository = signing, - ) - - @BeforeTest - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - } - - @AfterTest - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `the form opens what the schema asks for, one input per value, and keeps the rest`() { - val state = loaded() - val viewModel = viewModel() - viewModel.seedWorkingCopy(state) - - // Not the identifier -- it writes to `d` -- and not the derived hashtags. - assertEquals( - listOf("title", "year", "type", "watchUrl", "imdbUrl", "review"), - AcceptCuratedSuggestionViewModel.editableFields(state).map { it.name } - ) - assertEquals( - listOf("identifier", "hashtags"), - AcceptCuratedSuggestionViewModel.keptFields(state).map { it.name } - ) - - assertEquals(listOf("Magic Money"), viewModel.inputs["title"]?.map { it.text.toString() }) - assertEquals(listOf("2018"), viewModel.inputs["year"]?.map { it.text.toString() }) - // A field that repeats gets an input per value the suggestion carried. - assertEquals( - listOf("https://example.com/watch", "https://mirror.example/watch"), - viewModel.inputs["watchUrl"]?.map { it.text.toString() } - ) - // A field the suggestion has nothing for gets one blank to type into. - assertEquals(listOf(""), viewModel.inputs["imdbUrl"]?.map { it.text.toString() }) - assertNull(viewModel.inputs["identifier"], "the identifier was opened for editing") - assertNull(viewModel.inputs["hashtags"], "a derived field was opened for editing") - } - - @Test - fun `seeding again leaves an edit alone`() { - val state = loaded() - val viewModel = viewModel() - viewModel.seedWorkingCopy(state) - viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") - - viewModel.seedWorkingCopy(state) - - assertEquals("2019", viewModel.inputs["year"]!![0].text.toString()) - } - - @Test - fun `the draft is what was typed plus what was kept, and the d is the suggestion's`() { - val state = loaded() - val viewModel = viewModel() - viewModel.seedWorkingCopy(state) - - viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") - viewModel.addValue("watchUrl") - viewModel.inputs["watchUrl"]!![2].setTextAndPlaceCursorAtEnd("https://third.example/watch") - viewModel.removeValue("watchUrl", 0) - - val draft = viewModel.draft(state) - - assertEquals(listOf("magic-money"), draft["identifier"]) - assertEquals(listOf("2019"), draft["year"]) - assertEquals(listOf("https://mirror.example/watch", "https://third.example/watch"), draft["watchUrl"]) - assertEquals(listOf("bitcoin", "documentary"), draft["hashtags"]) - // A blank input comes through; the builder is what drops it. - assertEquals(listOf(""), draft["imdbUrl"]) - } - - @Test - fun `removing the last input leaves a blank one rather than nothing`() { - val state = loaded() - val viewModel = viewModel() - viewModel.seedWorkingCopy(state) - - viewModel.removeValue("year", 0) - - assertEquals(listOf(""), viewModel.inputs["year"]?.map { it.text.toString() }) - } - - @Test - fun `an entry the queue would refuse is refused with the field named, and nothing is proposed`() { - val state = loaded() - val signing = RecordingSigning(session()) - val viewModel = viewModel(signing) - viewModel.seedWorkingCopy(state) - - viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("1850") - viewModel.inputs["type"]!![0].setTextAndPlaceCursorAtEnd("musical") - - var invalid = false - viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { invalid = true }, onFailure = { error("failed") }) - - assertTrue(invalid) - assertEquals(listOf(CuratedEntryProblem.Reason.BelowMinimum), viewModel.problemsFor("year").map { it.reason }) - assertEquals(listOf(CuratedEntryProblem.Reason.NotAnOption), viewModel.problemsFor("type").map { it.reason }) - assertEquals(emptyList(), viewModel.problemsAboutTheEntry(state)) - assertNull(signing.kind, "a refused entry was proposed anyway") - assertFalse(viewModel.isActionPending.value) - } - - @Test - fun `a require-any rule the edit broke is filed under its first field`() { - val state = loaded() - val viewModel = viewModel() - viewModel.seedWorkingCopy(state) - - viewModel.removeValue("watchUrl", 0) - viewModel.removeValue("watchUrl", 0) - - viewModel.proposeEntry(state, onSuccess = {}, onInvalid = {}, onFailure = {}) - - assertEquals(listOf(CuratedEntryProblem.Reason.RequireAnyUnmet), viewModel.problemsFor("watchUrl").map { it.reason }) - } - - @Test - fun `the entry proposed is the canonical one the queue will read back`() { - val state = loaded() - val signing = RecordingSigning(session()) - val viewModel = viewModel(signing) - viewModel.seedWorkingCopy(state) - viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") - - var sessionId: String? = null - viewModel.proposeEntry(state, onSuccess = { sessionId = it }, onInvalid = { error("invalid") }, onFailure = { error("failed") }) - - runBlocking { - withTimeout(5_000) { - while (sessionId == null) kotlinx.coroutines.delay(10) - } - } - - assertEquals("session-1", sessionId) - assertEquals(CuratedEntryEvent.CANONICAL_KIND, signing.kind) - assertEquals("Follows the early community.", signing.content) - - val tags = assertNotNull(signing.tags).map { it.toList() } - assertTrue(listOf("d", "magic-money") in tags, "the entry lost the suggestion's d") - assertTrue(listOf("year", "2019") in tags, "the edit was not signed") - assertTrue(listOf("t", "bitcoin") in tags && listOf("t", "documentary") in tags, "the derived hashtags were dropped") - assertTrue(listOf("a", schema.coordinate(), "wss://relay.one.example", "root") in tags, "the entry does not reply to the list") - assertTrue(listOf("a", "31888:$bob:magic-money", "", "mention") in tags, "the entry does not point at the suggestion") - assertTrue(listOf("e", suggestion.id, "", "mention") in tags, "the entry does not pin the version read") - assertTrue(listOf("p", bob) in tags, "the suggester is not credited") - - // And it is what the queue counts as the group's entry for that suggestion. - val signed = NostrEvent( - id = "2".repeat(64), - pubKey = chatRoomId, - kind = CuratedEntryEvent.CANONICAL_KIND, - tags = signing.tags!!, - content = signing.content!!, - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_900), - ) - val queue = CuratedSuggestion.queueOf( - schema, - listOf(LocalNostrEvent(suggestedEvent, null), LocalNostrEvent(signed, null)) - ) - assertTrue(queue.single().isCurated, "the queue does not count the proposed entry as curating the suggestion") - } - - @Test - fun `a session that could not be opened is a failure, and the form stays`() { - val state = loaded() - val viewModel = viewModel(RecordingSigning(session = null)) - viewModel.seedWorkingCopy(state) - - var failed = false - viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { error("invalid") }, onFailure = { failed = true }) - - runBlocking { - withTimeout(5_000) { - while (!failed) kotlinx.coroutines.delay(10) - } - } - - assertEquals(emptyList(), viewModel.problems) - assertEquals("Magic Money", viewModel.inputs["title"]!![0].text.toString()) - } - - @Test - fun `the loader finds the suggestion by the queue's reading, and says when it cannot`() { - val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { - override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> = - flowOf(listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null))) - } - val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { - override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom - override suspend fun groupCuratedSchemas(chatRoomId: String): List = listOf(schema) - } - - val found = viewModel(RecordingSigning(null), nostrRepository, chatRepository) - found.initiate() - val loaded = await { found.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Loaded } - assertEquals("Magic Money", loaded.suggestion.entry.title) - assertTrue(loaded.canSign) - - val missing = viewModel(RecordingSigning(null), nostrRepository, chatRepository, suggestionId = "9".repeat(64)) - missing.initiate() - val error = await { missing.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Error } - assertEquals("Couldn't find that suggestion", error.message) - } - - private fun await(read: () -> T?): T = runBlocking { - withTimeout(5_000) { - var value = read() - while (value == null) { - kotlinx.coroutines.delay(10) - value = read() - } - value - } - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt index 9ef230c7..6c456094 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt @@ -1,10 +1,8 @@ package press.mantra.compose.ui.view.model -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -22,15 +20,8 @@ import press.mantra.compose.network.dto.RelayDTO import press.mantra.compose.network.relays.EventPublishTransport import press.mantra.compose.network.relays.NostrPublishResult import press.mantra.compose.network.sockets.NostrIncomingMessage -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.Relays -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedEntryEvent -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState @@ -48,9 +39,8 @@ import kotlin.time.Instant * * **Where an event goes before anyone says otherwise.** The seeded list is what a * member who reads nothing and presses the button sends to, so it has to be the - * group's own answer where the group has given one, has to leave out what the - * group has blocked, and has to widen for the two kinds whose readers would not - * otherwise find them. + * app's own publish set -- a group has no relay list of its own to prefer -- and + * it has to be seeded once, so a list the member emptied stays empty. * * **What each relay's answer becomes.** The pool answers once per relay in three * shapes -- an OK, a rejection wrapped in one exception type, and any other error @@ -118,27 +108,6 @@ class BroadcastGroupSignedEventViewModelJvmTest { createdAt = Instant.fromEpochSeconds(1_700_000_000), ) - private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent( - id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = set.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ) - - /** Every set unsigned, which is a group that has never said where it lives. */ - private fun nothingAgreed() = GroupRelayList.allAmong(emptyList(), chatRoomId) - - private fun agreed(vararg lists: Pair>) = - nothingAgreed().map { list -> - lists.firstOrNull { it.first == list.set } - ?.let { GroupRelayList(list.set, relaySignedEvent(list.set), it.second) } - ?: list - } - private fun ok(relay: NormalizedRelayUrl) = NostrPublishResult( result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true), relayUrl = relay.url, @@ -158,142 +127,32 @@ class BroadcastGroupSignedEventViewModelJvmTest { // -- where an event goes before anyone says otherwise ------------------------ @Test - fun `a group that has not said where it lives sends to the app's publish set`() { + fun `every kind is seeded with the app's publish set`() { + // A group has no relay list of its own, so a post and a key state go to + // the same place before the member says otherwise -- the set the app + // publishes its own events to, which is what keeps the button from + // being a no-op on a group nobody has typed a relay in for. assertEquals( Relays.eventPublishRelaySet.toList(), - BroadcastGroupSignedEventViewModel.defaultRelaysFor(nothingAgreed(), TextNoteEvent.KIND), + BroadcastGroupSignedEventViewModel.defaultRelaysFor(TextNoteEvent.KIND), ) - } - - @Test - fun `a group's own general list wins, and only the relays it writes to`() { - val relayLists = agreed( - GroupRelaySet.General to listOf( - GroupRelay(one), - // Read-only: where the group looks, not where it puts things. - GroupRelay(two, read = true, write = false), - ) - ) - - // The group's answer and nothing from the app's set beside it: a member - // who edited the relays meant them, and padding the list would send the - // group's post somewhere the group decided not to be. assertEquals( - listOf(one), - BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND), - ) - } - - @Test - fun `a blocked relay is dropped whatever the other lists say`() { - val relayLists = agreed( - GroupRelaySet.General to listOf(GroupRelay(one), GroupRelay(two)), - GroupRelaySet.Blocked to listOf(GroupRelay(two)), - ) - - assertEquals( - listOf(one), - BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND), - ) - } - - @Test - fun `a relay list also goes to the indexers, since its readers do not yet know the relays it names`() { - val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) - - val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor( - relayLists, AdvertisedRelayListEvent.KIND - ) - - assertEquals(one, seeded.first()) - Relays.DefaultIndexerRelayList.forEach { indexer -> - assertTrue(indexer in seeded, "the relay list would not reach ${indexer.url}") - } - - // And a post does not: nobody looks an indexer up for a kind 1. - val post = BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND) - Relays.DefaultIndexerRelayList.forEach { indexer -> - assertFalse(indexer in post, "a post was sent to the indexer ${indexer.url}") - } - } - - @Test - fun `a curated schema also goes to the relays it names for its own replies`() { - val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) - - val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor( - relayLists = relayLists, - kind = CuratedSchemaEvent.KIND, - tags = arrayOf( - arrayOf("d", "films"), - arrayOf("relay", three.url), - ), - content = "Films worth translating.", - ) - - // The group's relay first, then the list's own -- a client reading - // suggestions on the list's relay finds the schema they answer. - assertEquals(listOf(one, three), seeded) - } - - @Test - fun `an entry into a curated list goes to the list's relays, found by the coordinate it replies to`() { - val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) - - val films = GroupCuratedSchema( - signedEvent = relaySignedEvent(GroupRelaySet.General).copy(kind = CuratedSchemaEvent.KIND), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - relays = listOf(three.url), - ).normalized(), - ) - val books = films.copy(schema = films.schema.copy(identifier = "books", relays = listOf(two.url))) - - val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor( - relayLists = relayLists, - kind = CuratedEntryEvent.CANONICAL_KIND, - tags = arrayOf( - arrayOf("d", "magic-money"), - arrayOf("title", "Magic Money"), - arrayOf("a", films.coordinate(), "", "root"), - ), - content = "", - schemas = listOf(books, films), - ) - - // The group's relay, then the relays of the list replied to -- and not - // the other list's: the NIP sends an entry where its own schema says. - assertEquals(listOf(one, three), seeded) - - // An entry whose schema this device does not hold goes where the group - // publishes, since there is nothing else to go on. - assertEquals( - listOf(one), - BroadcastGroupSignedEventViewModel.defaultRelaysFor( - relayLists = relayLists, - kind = CuratedEntryEvent.CANONICAL_KIND, - tags = arrayOf(arrayOf("a", "31889:${"0".repeat(64)}:elsewhere", "", "root")), - schemas = listOf(books, films), - ) + BroadcastGroupSignedEventViewModel.defaultRelaysFor(TextNoteEvent.KIND), + BroadcastGroupSignedEventViewModel.defaultRelaysFor(GroupKeyStateEvent.KIND), ) } @Test fun `seeding happens once, so an emptied list stays empty`() { val viewModel = viewModel() - val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) val event = signedEvent() - viewModel.seedRelays(relayLists, event.kind, event.tags, event.content) - viewModel.seedRelays(relayLists, event.kind, event.tags, event.content) - assertEquals(listOf(one), viewModel.relays.toList()) + viewModel.seedRelays(event.kind) + viewModel.seedRelays(event.kind) + assertEquals(Relays.eventPublishRelaySet.toList(), viewModel.relays.toList()) - viewModel.removeRelay(one) - viewModel.seedRelays(relayLists, event.kind, event.tags, event.content) + viewModel.relays.toList().forEach { viewModel.removeRelay(it) } + viewModel.seedRelays(event.kind) assertEquals(emptyList(), viewModel.relays.toList()) } @@ -403,8 +262,8 @@ class BroadcastGroupSignedEventViewModelJvmTest { @Test fun `what goes on the wire is the event the group signed, field for field`() { val event = signedEvent( - kind = MetadataEvent.KIND, - tags = arrayOf(arrayOf("alt", "The group's profile")), + kind = GroupKeyStateEvent.KIND, + tags = arrayOf(arrayOf("alt", "The group's signing key")), ) val wire = BroadcastGroupSignedEventViewModel.wireEventOf(event).toNostrJsonObject() @@ -415,6 +274,6 @@ class BroadcastGroupSignedEventViewModelJvmTest { assertEquals(event.kind.toString(), wire["kind"]!!.jsonPrimitive.content) assertEquals(event.content, wire["content"]!!.jsonPrimitive.content) assertEquals(event.signature, wire["sig"]!!.jsonPrimitive.content) - assertEquals("""[["alt","The group's profile"]]""", wire["tags"].toString()) + assertEquals("""[["alt","The group's signing key"]]""", wire["tags"].toString()) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt deleted file mode 100644 index 73b3463f..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt +++ /dev/null @@ -1,335 +0,0 @@ -package press.mantra.compose.ui.view.model - -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.NostrEvent -import press.mantra.compose.database.model.SynchronizeNostrEventRequest -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.database.model.intermdiate.LocalNostrEvent -import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.nostr.CuratedSuggestion -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.nostr.Relays -import press.mantra.compose.nostr.curated.CuratedEntry -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * The decisions the queue screen's view model makes that nothing else can check. - * - * **Where the queue is read from.** The schema's relays, then the group's, then - * the default -- and getting the order wrong is quiet, because a queue read - * from the wrong relay is an empty queue with no error attached. - * - * **What is asked for.** One request per relay carrying the NIP's two queries, - * and the local watch over the same rows, so that what comes back is what is - * shown. - * - * **When looking stops.** Rows arriving end it; nothing arriving leaves it to - * the timer. A screen that stopped looking on an empty first read of the local - * table would call every list empty before its relays had been asked. - */ -class CuratedSuggestionListViewModelJvmTest { - private val chatRoomId = "d4c3b2a1".repeat(8) - private val ada = "a".repeat(64) - - private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") - private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") - - private fun schema(relays: List) = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ), - schema = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Public, - fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), - relays = relays, - ).normalized(), - ) - - private val localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = ada, - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ) - - private fun general(relays: List, agreed: Boolean = true) = GroupRelayList( - set = GroupRelaySet.General, - signedEvent = if (agreed) { - GroupSignedEvent( - id = "a".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = GroupRelaySet.General.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ) - } else { - null - }, - relays = relays, - ) - - @Test - fun `the schema's own relays come first, and two spellings of one are one`() { - val asked = CuratedSuggestionListViewModel.relaysToAsk( - schema(listOf("wss://relay.one.example", "wss://relay.two.example/", "wss://relay.one.example/")), - general = general(listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example")))), - ) - - assertEquals(listOf(one.url, two.url), asked) - assertEquals(2, asked.size, "one relay spelled two ways was asked twice") - } - - @Test - fun `a schema naming no relays is read from where the group reads`() { - val three = RelayUrlNormalizer.normalize("wss://relay.three.example") - val four = RelayUrlNormalizer.normalize("wss://relay.four.example") - - // The group's read relays, and not its write-only one: this is a read. - assertEquals( - listOf(three.url), - CuratedSuggestionListViewModel.relaysToAsk( - schema(emptyList()), - general = general(listOf(GroupRelay(three), GroupRelay(four, read = false, write = true))), - ) - ) - - // Nothing agreed, or agreed empty: the build's own relay rather than nobody. - assertEquals( - listOf(Relays.ephemeral.url), - CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = null) - ) - assertEquals( - listOf(Relays.ephemeral.url), - CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = general(emptyList())) - ) - assertEquals( - listOf(Relays.ephemeral.url), - CuratedSuggestionListViewModel.relaysToAsk( - schema(emptyList()), - general = general(listOf(GroupRelay(three)), agreed = false) - ) - ) - } - - @Test - fun `one request per relay, carrying the NIP's two queries for the coordinate`() { - val schema = schema(listOf(one.url)) - val requests = CuratedSuggestionListViewModel.requestsFor(schema, listOf(one.url, two.url)) - - assertEquals(listOf(one.url, two.url), requests.map { it.relayURL }) - assertTrue(requests.all { it.purpose == CuratedSuggestionListViewModel.PURPOSE }) - - val (suggestions, canonical) = requests.first().synchronizationFilters - val coordinate = "31889:$chatRoomId:films" - - assertEquals(listOf(31888), suggestions.kinds?.toList()) - assertEquals(mapOf("a" to listOf(coordinate)), suggestions.tags) - assertEquals(null, suggestions.authors, "suggestions are not scoped by author: anyone may make one") - - assertEquals(listOf(31890), canonical.kinds?.toList()) - assertEquals(listOf(chatRoomId), canonical.authors?.toList(), "canonical entries are the group's alone") - assertEquals(mapOf("a" to listOf(coordinate)), canonical.tags) - - // And the watch covers both, so what is asked for is what is shown. - val watched = schema.queueReadFilter() - assertEquals(listOf(31888, 31890), watched.kinds?.toList()) - assertEquals(mapOf("a" to listOf(coordinate)), watched.tags) - } - - @Test - fun `rows arriving end the looking, and an empty read does not`() { - val asking = CuratedSuggestionListUIState.Loaded( - localChatRoom = localChatRoom, - schema = schema(listOf(one.url)), - relays = listOf(one.url), - isAsking = true, - ) - - val stillEmpty = CuratedSuggestionListViewModel.withQueue(asking, emptyList()) - assertTrue(stillEmpty.isAsking, "an empty first read of the local table was taken as the relays' answer") - - val arrived = CuratedSuggestionListViewModel.withQueue(asking, listOf(row("Magic Money", 1_700_000_000))) - assertFalse(arrived.isAsking) - assertEquals(listOf("Magic Money"), arrived.suggestions.map { it.entry.title }) - - // Once the timer has called it empty, a later arrival is still shown. - val late = CuratedSuggestionListViewModel.withQueue(stillEmpty.copy(isAsking = false), listOf(row("Late", 1_700_000_900))) - assertFalse(late.isAsking) - assertEquals(listOf("Late"), late.suggestions.map { it.entry.title }) - } - - @Test - fun `opening the screen asks the schema's relays and shows what the table holds for them`() { - val schema = schema(listOf(one.url)) - val queued = mutableListOf() - val watched = mutableListOf() - - val stored = NostrEvent( - id = "1".repeat(64), - pubKey = ada, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = arrayOf( - arrayOf("d", "magic-money"), - arrayOf("title", "Magic Money"), - arrayOf("a", schema.coordinate(), "", "root"), - ), - content = "", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ) - - val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { - override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { - queued += synchronizeNostrEventRequests - } - - override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { - watched += synchronizationFilter - return flowOf(listOf(LocalNostrEvent(nostrEvent = stored, profile = null))) - } - } - - val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { - override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom - override suspend fun groupCuratedSchemas(chatRoomId: String): List = listOf(schema) - override suspend fun groupRelayLists(chatRoomId: String): List = emptyList() - } - - val viewModel = CuratedSuggestionListViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = ada, - relayHint = null, - identifier = "films", - initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - ) - - try { - viewModel.initiate() - - // The read runs on IO and lands on the state when it lands; the - // assertion waits for the rows rather than for a fixed time. - val loaded = runBlocking { - withTimeout(5_000) { - while (true) { - val state = viewModel.curatedSuggestionListUIState - if (state is CuratedSuggestionListUIState.Loaded && state.suggestions.isNotEmpty()) break - delay(20) - } - assertIs(viewModel.curatedSuggestionListUIState) - } - } - - assertEquals(listOf(one.url), loaded.relays) - assertEquals(listOf(one.url), queued.map { it.relayURL }) - assertEquals(listOf(schema.queueReadFilter()), watched) - assertEquals(listOf("Magic Money"), loaded.suggestions.map { it.entry.title }) - assertFalse(loaded.isAsking, "rows arrived and the screen is still looking") - } finally { - // The timer would otherwise run on for ten seconds after the test. - viewModel.viewModelScope.cancel() - } - } - - @Test - fun `a schema this device does not hold is an error rather than an empty queue`() { - val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { - override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom - override suspend fun groupCuratedSchemas(chatRoomId: String): List = emptyList() - } - - val viewModel = CuratedSuggestionListViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = ada, - relayHint = null, - identifier = "films", - initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, - nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = chatRepository, - ) - - try { - viewModel.initiate() - - runBlocking { - withTimeout(5_000) { - while (viewModel.curatedSuggestionListUIState == CuratedSuggestionListUIState.Loading) delay(20) - } - } - - assertEquals( - CuratedSuggestionListUIState.Error("Couldn't find that schema"), - viewModel.curatedSuggestionListUIState - ) - } finally { - viewModel.viewModelScope.cancel() - } - } - - private fun row(title: String, createdAt: Long) = CuratedSuggestion( - entry = CuratedEntry( - kind = CuratedSchemaEvent.SUGGESTION_KIND, - id = createdAt.toString().padStart(64, 'e'), - pubKey = ada, - createdAt = createdAt, - identifier = title.lowercase().replace(' ', '-'), - title = title, - values = mapOf("title" to listOf(title)), - ), - event = NostrEvent( - id = createdAt.toString().padStart(64, 'e'), - pubKey = ada, - kind = CuratedSchemaEvent.SUGGESTION_KIND, - tags = emptyArray(), - content = "", - sig = "b".repeat(128), - createdAt = Instant.fromEpochSeconds(createdAt), - ), - suggester = null, - isCurated = false, - ) -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt deleted file mode 100644 index 49f8943f..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt +++ /dev/null @@ -1,382 +0,0 @@ -package press.mantra.compose.ui.view.model - -import press.mantra.compose.database.model.ChatRoom -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.database.model.intermdiate.LocalChatRoom -import press.mantra.compose.extensions.hexToNpubHrp -import press.mantra.compose.nostr.GroupCuratedSchema -import press.mantra.compose.nostr.curated.CuratedField -import press.mantra.compose.nostr.curated.CuratedFieldConfig -import press.mantra.compose.nostr.curated.CuratedFieldType -import press.mantra.compose.nostr.curated.CuratedSchema -import press.mantra.compose.nostr.curated.CuratedSchemaEvent -import press.mantra.compose.nostr.curated.CuratedSchemaProblem -import press.mantra.compose.nostr.curated.CuratedVisibility -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * The decisions the schema editor makes that nothing else can check. - * - * **What a new list starts as.** The two fields every list has and the group's - * own relays, so that what the member sees is what the group will sign rather - * than what `CuratedSchema.normalized` will quietly add. - * - * **What cannot be taken away.** A field writing to `d` or `title` cannot be - * removed, because the event would only grow it back -- so the editor has to - * refuse rather than let a member believe they removed it. - * - * **What counts as a change.** The button proposes only when the schema differs - * from the one the group signed, and getting that wrong is quiet in both - * directions: too eager and every visit re-dates a decision the group did not - * make; too shy and an edit is dropped on a screen that said it had been sent. - */ -class EditGroupCuratedSchemaViewModelJvmTest { - private val chatRoomId = "d4c3b2a1".repeat(8) - - private val films = CuratedSchema( - identifier = "films", - title = "Film suggestion", - name = "Films worth translating", - description = "Films the group would subtitle.", - visibility = CuratedVisibility.Closed, - fields = listOf( - CuratedField.IDENTIFIER, - CuratedField.TITLE, - CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "watch")), - CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "imdb")), - ), - requireAny = listOf(listOf("watchUrl", "imdbUrl")), - suggesters = listOf("e".repeat(64)), - relays = listOf("wss://relay.one.example"), - ) - - private fun viewModel(identifier: String? = null) = EditGroupCuratedSchemaViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = "a".repeat(64), - relayHint = null, - identifier = identifier, - initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loading, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - ) - - private val localChatRoom = LocalChatRoom( - chatRoom = ChatRoom( - id = chatRoomId, - userPublicKey = "a".repeat(64), - subject = "Translation room", - description = null, - initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = "state" - ) - ) - - /** The schema as the group signed it. Not verified here: the editor does not. */ - private fun existing(schema: CuratedSchema = films) = GroupCuratedSchema( - signedEvent = GroupSignedEvent( - id = "c".repeat(64), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = CuratedSchemaEvent.KIND, - tags = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags, - content = schema.description, - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ), - schema = schema.normalized(), - ) - - private fun newSchema(defaultRelays: List = listOf("wss://relay.one.example")) = - EditGroupCuratedSchemaUIState.Loaded( - localChatRoom = localChatRoom, - existing = null, - defaultRelays = defaultRelays, - canSign = true, - ) - - private fun editing(schema: CuratedSchema = films) = EditGroupCuratedSchemaUIState.Loaded( - localChatRoom = localChatRoom, - existing = existing(schema), - defaultRelays = listOf("wss://relay.two.example"), - canSign = true, - ) - - @Test - fun `a new schema starts with the two fields every list has and the group's relays`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - assertEquals(listOf("d", "title"), viewModel.fields.map { it.tag() }) - assertTrue(viewModel.fields.all { it.isRequired }) - assertEquals(CuratedVisibility.Public, viewModel.visibility) - assertEquals(listOf("wss://relay.one.example"), viewModel.relays) - assertEquals(emptyList(), viewModel.requireAny) - assertEquals(emptyList(), viewModel.suggesters) - } - - @Test - fun `an existing schema starts as itself, and its relays are its own`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - assertEquals(films.fields, viewModel.fields.toList()) - assertEquals(films.requireAny, viewModel.requireAny.toList()) - assertEquals(films.suggesters, viewModel.suggesters.toList()) - assertEquals(CuratedVisibility.Closed, viewModel.visibility) - // Not the defaults the state also carries: those are for a new list. - assertEquals(listOf("wss://relay.one.example"), viewModel.relays) - } - - @Test - fun `seeding again leaves an edit alone`() { - // The effect that seeds is keyed on a state the view model can re-emit, so - // running twice has to be harmless -- otherwise a reload halfway through an - // edit throws the edit away. - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - assertTrue(viewModel.addRelay("wss://relay.two.example")) - - viewModel.seedWorkingCopy(editing()) - - // The added relay is normalized, which is how the group's other relay - // lists are written; the signed one is kept as the group wrote it. - assertEquals(listOf("wss://relay.one.example", "wss://relay.two.example/"), viewModel.relays) - } - - @Test - fun `a relay already signed into the schema is a duplicate with or without the slash`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - assertFalse(viewModel.addRelay("wss://relay.one.example")) - assertFalse(viewModel.addRelay("wss://relay.one.example/")) - assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure) - assertEquals(listOf("wss://relay.one.example"), viewModel.relays) - } - - @Test - fun `a mandatory field cannot be removed and another one can`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - assertTrue(viewModel.isMandatory(viewModel.fields[0])) - assertTrue(viewModel.isMandatory(viewModel.fields[1])) - assertFalse(viewModel.isMandatory(viewModel.fields[2])) - - viewModel.removeField(0) - viewModel.removeField(1) - assertEquals(listOf("identifier", "title", "watchUrl", "imdbUrl"), viewModel.fields.map { it.name }) - - // A field that goes takes its place in the rules with it: a rule naming a - // field that no longer exists would silently never be satisfied. - viewModel.removeField(2) - assertEquals(listOf("identifier", "title", "imdbUrl"), viewModel.fields.map { it.name }) - assertEquals(listOf(listOf("imdbUrl")), viewModel.requireAny.toList()) - } - - @Test - fun `renaming a field follows through to the rules that named it`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - viewModel.startEditingField(2) - assertNull(viewModel.commitField(viewModel.fields[2].copy(name = "watch"))) - - assertEquals(listOf("identifier", "title", "watch", "imdbUrl"), viewModel.fields.map { it.name }) - assertEquals(listOf(listOf("watch", "imdbUrl")), viewModel.requireAny.toList()) - assertNull(viewModel.editingField, "the sheet stayed open after a commit") - } - - @Test - fun `a field name is one word and belongs to one field`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - viewModel.startAddingField() - val draft = viewModel.editingField!!.field - - assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameMissing, viewModel.commitField(draft.copy(name = " "))) - assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameHasSpaces, viewModel.commitField(draft.copy(name = "watch url"))) - assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameTaken, viewModel.commitField(draft.copy(name = "imdbUrl"))) - assertEquals(4, viewModel.fields.size, "a refused field went in anyway") - - assertNull(viewModel.commitField(draft.copy(name = " year "))) - assertEquals("year", viewModel.fields.last().name) - - // Editing a field back to its own name is not taking it from anybody. - viewModel.startEditingField(4) - assertNull(viewModel.commitField(viewModel.fields[4].copy(label = "Year"))) - } - - @Test - fun `a rule with fewer than two names is dropped from the draft rather than refused`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - viewModel.addRequireAnyGroup() - viewModel.toggleRequireAny(0, "title") - viewModel.addRequireAnyGroup() - - val draft = viewModel.draft("d", "t", "n", "desc", "", "") - - assertEquals(2, viewModel.requireAny.size, "the editor lost a rule being built") - assertEquals(emptyList(), draft.requireAny) - } - - @Test - fun `a pubkey is an npub or hex and nothing else`() { - val hex = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" - // Encoded rather than typed, so the checksum is right by construction. - val npub = hex.hexToNpubHrp() - - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(hex)) - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(" ${hex.uppercase()} ")) - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(npub)) - assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull("nostr:$npub")) - - listOf("", "npub1notanpub", hex.dropLast(1), "group@example.com", "nsec1" + npub.drop(5)).forEach { - assertNull(EditGroupCuratedSchemaViewModel.pubkeyOrNull(it), "\"$it\" was taken for a pubkey") - } - - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - assertFalse(viewModel.addSuggester("group@example.com")) - assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.NotAPubkey, viewModel.addSuggesterFailure) - assertTrue(viewModel.addSuggester(npub)) - assertFalse(viewModel.addSuggester(hex)) - assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.AlreadyListed, viewModel.addSuggesterFailure) - assertEquals(listOf(hex), viewModel.suggesters) - } - - @Test - fun `a url that is not a relay is refused and named`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema(defaultRelays = emptyList())) - - assertFalse(viewModel.addRelay("http://relay.one.example")) - assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.NotARelay, viewModel.addRelayFailure) - // The NIP allows ws://; what the group signs is held to the app's rule. - assertFalse(viewModel.addRelay("ws://relay.one.example")) - - assertTrue(viewModel.addRelay("wss://relay.one.example")) - assertFalse(viewModel.addRelay("wss://relay.one.example")) - assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure) - } - - @Test - fun `opening a schema and pressing the button proposes nothing`() { - // The case that has to be silent. A member opening the editor, reading it - // and pressing the button must not spend a quorum on an identical schema. - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - var outcome = "" - viewModel.proposeSchema( - localChatRoom = localChatRoom, - existing = existing(), - identifier = films.identifier, - title = films.title, - name = films.name, - description = films.description, - picture = "", - domain = "", - onSuccess = { outcome = "proposed" }, - onInvalid = { outcome = "invalid" }, - onNothingToPropose = { outcome = "nothing" }, - onFailure = { outcome = "failed" }, - ) - - assertEquals("nothing", outcome) - assertEquals(emptyList(), viewModel.problems) - } - - @Test - fun `an edit keeps the identifier whatever the field says`() { - val viewModel = viewModel("films") - viewModel.seedWorkingCopy(editing()) - - var outcome = "" - viewModel.proposeSchema( - localChatRoom = localChatRoom, - existing = existing(), - // A different identifier would be a different list, not an edit. - identifier = "books", - title = films.title, - name = films.name, - description = films.description, - picture = "", - domain = "", - onSuccess = { outcome = "proposed" }, - onInvalid = { outcome = "invalid" }, - onNothingToPropose = { outcome = "nothing" }, - onFailure = { outcome = "failed" }, - ) - - assertEquals("nothing", outcome, "a changed identifier was taken as an edit") - } - - @Test - fun `a schema a reader would refuse is refused here, and the reasons are kept`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - var outcome = "" - viewModel.proposeSchema( - localChatRoom = localChatRoom, - existing = null, - identifier = " ", - title = "t".repeat(201), - name = "Films", - description = "", - picture = "http://example.com/x.png", - domain = "not a domain", - onSuccess = { outcome = "proposed" }, - onInvalid = { outcome = "invalid" }, - onNothingToPropose = { outcome = "nothing" }, - onFailure = { outcome = "failed" }, - ) - - assertEquals("invalid", outcome) - assertEquals( - listOf( - CuratedSchemaProblem.IdentifierMissing, - CuratedSchemaProblem.TitleTooLong, - CuratedSchemaProblem.DescriptionMissing, - CuratedSchemaProblem.PictureNotHttps, - CuratedSchemaProblem.DomainInvalid, - ), - viewModel.problems, - ) - } - - @Test - fun `the draft trims what was typed and normalizes the domain`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(newSchema()) - - val draft = viewModel.draft( - identifier = " films ", - title = " Film suggestion ", - name = " Films ", - description = " Films. ", - picture = " ", - domain = " https://Example.COM/ ", - ) - - assertEquals("films", draft.identifier) - assertEquals("Film suggestion", draft.title) - assertEquals("Films", draft.name) - assertEquals("Films.", draft.description) - assertNull(draft.picture) - assertEquals("example.com", draft.domain) - assertTrue(draft.isUsable()) - } -} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt deleted file mode 100644 index 91ac1332..00000000 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -package press.mantra.compose.ui.view.model - -import press.mantra.compose.database.model.GroupSignedEvent -import press.mantra.compose.nostr.GroupRelay -import press.mantra.compose.nostr.GroupRelayList -import press.mantra.compose.nostr.GroupRelaySet -import press.mantra.compose.nostr.Relays -import press.mantra.compose.repository.ChatRepository -import press.mantra.compose.repository.FrostSigningRepository -import press.mantra.compose.ui.view.state.EditGroupRelaysUIState -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.time.Instant - -/** - * The two decisions the relay editor makes that nothing else can check. - * - * **What counts as a change.** The button proposes only the lists that differ, so - * this is what decides whether a quorum is asked at all -- and getting it wrong in - * either direction is quiet. Too eager and every visit re-signs four lists nobody - * touched, dating a decision the group did not make; too shy and an edit is - * silently dropped on a screen that said it had been sent. - * - * **What a NIP-65 marker is allowed to become.** A relay that is neither read nor - * write has no tag in the format, so the state must not be reachable. - * `GroupRelaySet.template` drops one as a last resort; this is where it is actually - * prevented. - */ -class EditGroupRelaysViewModelJvmTest { - private val chatRoomId = "d4c3b2a1".repeat(8) - - private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") - private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") - - private fun viewModel() = EditGroupRelaysViewModel( - chatRoomId = chatRoomId, - activeUserPublicKey = "a".repeat(64), - relayHint = null, - initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loading, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, - ) - - private fun signedEvent(set: GroupRelaySet) = GroupSignedEvent( - id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), - chatRoomId = chatRoomId, - publicKey = chatRoomId, - kind = set.kind, - tags = emptyArray(), - content = "", - signature = "f".repeat(128), - createdAt = Instant.fromEpochSeconds(1_700_000_000), - ) - - /** Every set unsigned, which is a group opening the editor for the first time. */ - private fun nothingSigned() = GroupRelayList.allAmong(emptyList(), chatRoomId) - - private fun signed(set: GroupRelaySet, relays: List) = - nothingSigned().map { - if (it.set == set) GroupRelayList(set, signedEvent(set), relays) else it - } - - @Test - fun `a first-time group is seeded with the app's own relay and that is a change`() { - val signed = nothingSigned() - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - assertEquals( - listOf(Relays.ephemeral), - viewModel.relaysOf(GroupRelaySet.General).map { it.url }, - ) - - // Seeded and therefore proposable. A group whose editor filled itself in - // and then refused to send it would be showing a plan it will not carry out. - assertEquals( - listOf(GroupRelaySet.General, GroupRelaySet.Messages, GroupRelaySet.Search), - viewModel.changedSets(signed), - ) - } - - @Test - fun `blocked is never seeded and so is never proposed by simply opening the screen`() { - val signed = nothingSigned() - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - assertEquals(emptyList(), viewModel.relaysOf(GroupRelaySet.Blocked)) - assertFalse(GroupRelaySet.Blocked in viewModel.changedSets(signed)) - } - - @Test - fun `opening a group that has agreed everything proposes nothing`() { - // The case that has to be silent. A member opening the editor, reading it - // and pressing the button must not spend a quorum on four identical lists. - val signed = GroupRelaySet.entries.map { set -> - GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) - } - - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - assertEquals(emptyList(), viewModel.changedSets(signed)) - } - - @Test - fun `only the set that was edited is proposed`() { - val signed = GroupRelaySet.entries.map { set -> - GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) - } - - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - viewModel.selectSet(GroupRelaySet.Search) - assertTrue(viewModel.addRelay(two.url)) - - assertEquals(listOf(GroupRelaySet.Search), viewModel.changedSets(signed)) - } - - @Test - fun `reordering a list is a change`() { - // A relay list is written in the order it is read back, and a member who - // moved a relay to the front meant to. Comparing as sets would call this no - // change and refuse to propose it. - val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one), GroupRelay(two))) - - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - viewModel.selectSet(GroupRelaySet.General) - viewModel.removeRelay(GroupRelaySet.General, GroupRelay(one)) - assertTrue(viewModel.addRelay(one.url)) - - assertEquals( - listOf(two, one), - viewModel.relaysOf(GroupRelaySet.General).map { it.url }, - ) - assertTrue(GroupRelaySet.General in viewModel.changedSets(signed)) - } - - @Test - fun `emptying an agreed list is a change, and emptying an unagreed one is not`() { - // Withdrawing a list the group agreed is a decision worth a signature. - // "Still nothing" is not a decision at all, and proposing an empty list for - // a set nobody ever set would be a signature over silence. - val agreed = signed(GroupRelaySet.Messages, listOf(GroupRelay(one))) - val withdrawing = viewModel() - withdrawing.seedWorkingCopy(agreed) - withdrawing.removeRelay(GroupRelaySet.Messages, GroupRelay(one)) - - assertTrue(GroupRelaySet.Messages in withdrawing.changedSets(agreed)) - - val untouched = nothingSigned() - val quiet = viewModel() - quiet.seedWorkingCopy(untouched) - // Clear the seeded default so the set is unagreed *and* empty. - quiet.removeRelay(GroupRelaySet.Search, GroupRelay(Relays.ephemeral)) - - assertFalse(GroupRelaySet.Search in quiet.changedSets(untouched)) - } - - @Test - fun `a relay cannot be turned into one that is neither read nor write`() { - // NIP-65 has no tag for it, so the state has to be unreachable. What it - // would mean is that the relay is not in the list, and removing it is the - // row's own button. - val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - - viewModel.toggleWrite(GroupRelaySet.General, GroupRelay(one)) - assertEquals( - listOf(GroupRelay(one, read = true, write = false)), - viewModel.relaysOf(GroupRelaySet.General), - ) - - // The second toggle is the one that would leave it as neither. - viewModel.toggleRead( - GroupRelaySet.General, - viewModel.relaysOf(GroupRelaySet.General).first(), - ) - assertEquals( - listOf(GroupRelay(one, read = true, write = false)), - viewModel.relaysOf(GroupRelaySet.General), - "a relay was left neither read nor write, which NIP-65 cannot express", - ) - } - - @Test - fun `a url that is not a relay is refused and named`() { - val viewModel = viewModel() - viewModel.seedWorkingCopy(nothingSigned()) - viewModel.selectSet(GroupRelaySet.Search) - - assertFalse(viewModel.addRelay("http://relay.one.example")) - assertEquals(EditGroupRelaysViewModel.AddFailure.NotARelay, viewModel.addFailure) - - // And a duplicate is a no-op rather than a mistake, said differently. - assertTrue(viewModel.addRelay(one.url)) - assertFalse(viewModel.addRelay(one.url)) - assertEquals(EditGroupRelaysViewModel.AddFailure.AlreadyListed, viewModel.addFailure) - } - - @Test - fun `seeding again leaves an edit alone`() { - // The effect that seeds is keyed on a state the view model can re-emit, so - // running twice has to be harmless -- otherwise a reload halfway through - // typing throws the typing away. - val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) - val viewModel = viewModel() - viewModel.seedWorkingCopy(signed) - viewModel.selectSet(GroupRelaySet.General) - assertTrue(viewModel.addRelay(two.url)) - - viewModel.seedWorkingCopy(signed) - - assertEquals( - listOf(one, two), - viewModel.relaysOf(GroupRelaySet.General).map { it.url }, - ) - } -} diff --git a/docs/README.md b/docs/README.md index 6e93b973..6f61a60d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,8 +40,10 @@ only one about what the app looks like rather than what it does; read the jvm-target note first if you want to know why its adaptive-layout phase exists. The curated-to-mantra note is a phased plan that has been built, save for its last phase, which is a decision: it is about the repository rather than the app, and reads -alone, except that its first decision leans on the derivation note's one rule. The -nsec and npub sign-in notes arrived with it and record what they built. +alone, except that its first decision leans on the derivation note's one rule. Two of +the lines it pulled -- the group's nostr identity and the curated lists -- were taken +out again the same day, and its record says what went and what stayed. The nsec and +npub sign-in notes arrived with it and record what they built. The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md index 21629146..0836f46f 100644 --- a/docs/curated-to-mantra.md +++ b/docs/curated-to-mantra.md @@ -32,6 +32,31 @@ says so, and it did so in seven places worth reading: | the exactness diff is the logo plus `808a3459`'s three files | plus the fourteen files Mantra changed on its own side: Phase 0's prose at the protected names, `39fb64b6`'s four, and this document with its two scripts. Seventeen files, every one accounted for, at every cut | | 1,036 jvm tests at the end | 1,039 — the three from `39fb64b6` — and per phase 736, 864, 905, 1,007, 1,039; `testDebugUnitTest` 403, 486, 495, 530, 530; every phase clean on both compilers with every audit budget met | +**Lines B and D were taken out again the same day**, and H with them, by a product +decision rather than a merge problem: the group's nostr profile, its relay lists, its +posts, the curated schemas and the curated entries are not Mantra's product, and the +[third decision](#3-curated-lists-are-not-mantras-product-and-mantra-should-take-them-anyway)'s +"hide the two rows behind a constant" was not enough once the profile, relays and +posts rows went with them. What went: the five rows and the twelve screens behind +them, `nostr/curated/`, the readings (`GroupNostrProfile`, `GroupRelayList`, +`GroupPost`, `GroupCuratedSchema`, `GroupCuratedEntry`, `CuratedSuggestion`), the +`applyInnerEvent` arms for kinds 0, 1, the four relay-list kinds, 31889 and 31890, +the `ChatRepository` and `NostrRepository` reads that fed them, the pasted-event +proposal (`GroupEventProposal`, whose accepted kinds were exactly those), the +`ProposedEvent` summaries for the same kinds, 226 strings, and every test that came +with them. What stayed from the pull: F (the back button), G and I (sign-in), J (the +library chore), and E (broadcast) -- which nothing opens now, since its button sat on +the five removed screens and its relay seeding read the group's relay lists and +schemas; `defaultRelaysFor` is the app's publish set, and the route and the button +are kept against the day something wires one in. Every pre-existing file the removed +commits touched is back at its pre-pull content plus the kept lines' hunks; the +exactness check for that is `git diff ba0830a3 -- ` showing only the back +button, broadcast's `groupSignedEvent`, and the sign-in reads. Tests afterwards: 826 +jvm, 413 common, every audit budget met. The seam this leaves for the next pull is the +one the third decision predicted: any upstream commit that touches the identity block +will conflict at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and the +resolution is to drop it. + The upstream moved before this landed: `curated/curated` went from `86cb876b` to `29027f2b`, ten commits for several profiles on one device, one of them the fork's first schema migration (version 20). They are the next pull, and the schema change is diff --git a/docs/scripts/m3-title-case.py b/docs/scripts/m3-title-case.py index 51f154c5..02a4c11a 100755 --- a/docs/scripts/m3-title-case.py +++ b/docs/scripts/m3-title-case.py @@ -29,8 +29,6 @@ SMALL = {'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'via', SAMPLE = { 'Steve Biko', 'John Doe', 'Frank Talk', 'Alan Turing', 'To Kill a Mockingbird', 'Man With A Plan', 'Woman Of Few Words', - # Films, as suggested to a curated list in the queue screen's preview. - 'The Rise and Rise of Bitcoin', 'Magic Money', } From 5f2414dcb5076519c8962e0e1f5df41d79d3a033 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 15:41:24 +0200 Subject: [PATCH 41/71] docs: plan the second pull from the fork, with its dry run already done The upstream moved the day the first pull landed: ten commits on curated/curated, 86cb876b..29027f2b, that let a device hold several profiles and move between them, and with them the fork's first schema migration, version 20. The first plan named them the next pull and said the schema change earned a decision of its own. This is that plan, and unlike the first it was written after the dry run rather than before it, so its numbers are measured: rewrite from the fork point in 103 s, ten commits replayed onto 776455ec with nine clean and one resolved by rule, both compilers clean, jvmTest 826 -> 868 and testDebugUnitTest 413 -> 420 with no failures, every audit budget met, and 20.json regenerated byte-identical after a full build. The verdict is all ten, because the line is one feature and seven fixes braided together and four of the fixes are live on Mantra today with one profile: the relay observer that is never all cancelled, the read-only inbox that stays closed after its nsec is pasted, the DataStore race on desktop, and the startup screen's wallet-worded literals. Four decisions: take the whole line rather than carve the fixes out of five commits; take upstream's version 20 verbatim and adopt the rule that whichever tree migrates first owns the number; the one conflict is Mantra's own import from 39fb64b6, resolved theirs, which closes one of the first plan's owed reverse-pulls; and two of the device's profiles in one group is a Mantra follow-up before release, since ChatRoom is keyed by the group id alone. What changed in the method: Mantra's tree is no longer a superset of the fork's, so the exactness check becomes "the residual between the trees is the same before and after the pull, file for file", and the plan gives the commands. The replay driver gains the one rule the dry run needed, with its reason. The README gets the row and a reading-order sentence, and the first plan points forward from the paragraph that predicted this one. Co-Authored-By: Claude Opus 5 --- docs/README.md | 7 +- docs/curated-to-mantra-profiles.md | 527 +++++++++++++++++++++++++++++ docs/curated-to-mantra.md | 4 +- docs/scripts/curated-replay.py | 5 +- 4 files changed, 540 insertions(+), 3 deletions(-) create mode 100644 docs/curated-to-mantra-profiles.md diff --git a/docs/README.md b/docs/README.md index 6f61a60d..dacca806 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ silent, or a decision that looked arbitrary and was not. | [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 | | [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 | Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" @@ -43,7 +44,11 @@ phase, which is a decision: it is about the repository rather than the app, and alone, except that its first decision leans on the derivation note's one rule. Two of the lines it pulled -- the group's nostr identity and the curated lists -- were taken out again the same day, and its record says what went and what stayed. The nsec and -npub sign-in notes arrived with it and record what they built. +npub sign-in notes arrived with it and record what they built. The profiles pull note +is the same exercise a second time, planned with its dry run already done; read it +after the first, because it assumes the method and the three decisions and only says +what changed — chiefly that the tree is no longer a superset, and what the check for an +exact pull becomes when it is not. The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md new file mode 100644 index 00000000..df663e8e --- /dev/null +++ b/docs/curated-to-mantra-profiles.md @@ -0,0 +1,527 @@ +# Pulling Curare's several-profiles line back into Mantra + +The [first pull](./curated-to-mantra.md) landed on 2026-09-13 and the upstream moved +the same day: `curated/curated` went from `86cb876b` to `29027f2b`, ten commits that +let a device hold several profiles and move between them — and, with them, the fork's +first schema migration, version 20. That plan named them the next pull and said the +schema change earned them a decision of their own rather than a ride. This is that +decision, which of the ten Mantra wants, in what order, and how they land — with the +dry run already done, so the numbers below are measured rather than predicted. + +Read [multiple-profiles.md](./multiple-profiles.md) for the line's own reasoning once +Phase 2 brings it; nothing here repeats it. Read the first pull's +[three decisions](./curated-to-mantra.md#three-decisions) and its +[Phase 1 record](./curated-to-mantra.md#phase-1--the-dry-run) for the method and the +lessons; this document assumes both and adds what changed. The one rule everything +still rests on is the derivation note's: two strings in this tree are hash inputs and +are never renamed, and the normaliser cannot touch them by construction. + +Written against `mantra` at `776455ec` — `origin/mantra` at `0fa807a6` plus the +removal commit, which is where this plan starts and which `origin` does not yet carry +— and `curated/curated` at `29027f2b`. Dry run executed 2026-09-13. + +## The shape of the fork now + +Since the first pull, Mantra's tree has stopped being a superset of Curare's non-brand +tree. It took thirty of the fork's thirty-nine commits, then took three lines out again +— the group's nostr identity, the curated lists, and the row-per-kind group screen — by a +product decision recorded in the first plan. So the two trees now differ in 135 files: +83 that Curare has and Mantra does not (the twelve removed screens and their view +models, `nostr/curated/`, the seven group readings, and `WalletManagerExtension.kt`, +which `39fb64b6` deleted on Mantra's side), 4 that Mantra has and Curare does not (the +first plan, its two scripts, and `NostrPublicKeyJvmTest`), and 48 that both have with +different content — `strings.xml`, `MantraNavHost.kt`, `ChatRoomDetailScreen.kt` and +the rest of the removal's footprint. That residual is the seam every future pull has to +cross, and the first plan predicted that anything upstream touching the identity block +would conflict in it. + +The ten commits, all of 2026-09-13, all on one line with no merges: + +``` +* 29027f2b docs: record what the multiple-profiles plan built (record) +* 88577bb5 test(identity): several profiles, both ways, and a boot (Phase 8) +* 9bb34004 feat(ui): leaving one profile among several (Phase 7) +* 759199f2 feat(sync): a fetch queue per identity, the inbox reopened (Phase 6, schema v20) +* 2f420230 feat(identity): adding a profile from inside (Phase 5) +* 25464595 feat(ui): the switcher (Phase 4) +* 00fc9969 feat(identity): which profile opens (Phase 3) +* 9b3a1927 feat(identity): a switch that tears down what it should (Phase 2) +* 3008137e feat(identity): a seed's key is a credential (Phase 1) +* a5ff2641 docs: plan several profiles on a device (plan) +``` + +Three facts about them decide most of what follows. + +**They touch none of what Mantra removed.** The ten change 69 files. Of those, six +already differ between the trees, and in only four of the six is the difference the +removal's — `strings.xml`, `MantraNavHost.kt`, `DatabaseNostrRepository.kt`, +`NostrRepository.kt` — and in all four the removal's hunks and this line's hunks are far +apart. The seam the first plan predicted is real and this line does not cross it: nine of +the ten replay without a conflict, and the tenth conflicts on a line that is Mantra's own +([the third decision](#3-the-one-conflict-is-mantras-and-it-closes-a-loop)). + +**No library change.** The pin is `84cc44c` on both sides, unchanged by the ten; +`BusinessManager.stopBusiness`, `GlobalPrefs.saveDefaultWallet` and the credentials +file's format all existed already. This is the first pull from the fork in which the pin +does not have to follow any commit. + +**One schema migration**, `759199f2`: version 20, two nullable columns by +`AutoMigration`, the export committed beside its predecessors. Mantra has no version 20 +of its own on any branch — measured: `curated/curated` is the only ref in this +repository carrying a `20.json` — so the number is free, and after the pull Room +regenerates nothing: the built tree's `20.json` is byte-identical to the fork's. The +[second decision](#2-schema-version-20-and-who-allocates-the-number) is about the +number, not the columns. + +## The inventory + +Every commit, what it is, what it does to Mantra *as it is today*, and the verdict. +Sizes are `git show --shortstat`; `759199f2`'s is 5,698 lines of generated schema plus +395 of code. + +| commit | phase | size | what Mantra gets | verdict | +|---|---|---|---|---| +| `a5ff2641` | plan | 2 files, +1,192 | `docs/multiple-profiles.md` and its README row | **pull** | +| `3008137e` | 1 | 16 files, +649 −111 | a seed's nostr key written to `nostr-credentials.dat` as a `Secret`, repaired in for every seed already on the device; `StoredIdentity.merge` inverted so the credentials file is the list of profiles; the identity's key read from the credential rather than from the running node; `forgetNostrCredential` refusing a key a seed derives | **pull** — the on-disk change; see [Phase 2](#phase-2--the-credential-alone) | +| `9b3a1927` | 2 | 9 files, +490 −31 | `switchToWallet` becomes `switchToIdentity` and stops the node it leaves; `RelaysSocketManager.observeActiveUserId` restructured under `collectLatest` so a switch cancels the previous identity's relay observer; `stopPlatformBusiness` as three one-line actuals | **pull** — a fix Mantra has today, see below | +| `00fc9969` | 3 | 7 files, +233 −22 | `StartupChoice.resolve`, the startup precedence lifted out of the screen and reordered; `setActiveIdentity` saves the default, the forget tails clear it; the startup screen's six literals into the catalogue as *profile* words | **pull** | +| `25464595` | 4 | 13 files, +547 −24 | `ProfilesRoute`/`ProfilesScreen`, reached from the profile tab's row that today goes to `ImplementationPendingRoute`; `WalletsSelector` rows showing the nostr profile instead of "Default name" over a random emoji, and "Wallet" beside a seed | **pull** | +| `2f420230` | 5 | 24 files, +624 −201 | *sign in with a key* and *create a new profile* under both lists; a profile created from inside is a bare key through `NewProfileWriter`, a seed only from Landing; *end this* and its `wipeDatabase` gone from the create screen; a pasted key already on the device offers *switch to it*; `JvmGlobalPrefs` closing a DataStore race on desktop | **pull** — two fixes Mantra has today, see below | +| `759199f2` | 6 | 17 files, +6,093 −23 | `ownerPublicKey` on the two fetch queues, stamped by the repository, drained by the owner's pump; `NostrDao.reopenInbox` opening every wrap addressed to the active key that no seal names, once per activation; `GiftWrapSeal.giftWrapMessageId` written on the path that never wrote it; **schema v20** | **pull** — a fix Mantra has today, and the decision | +| `9bb34004` | 7 | 3 files, +61 −1 | *switch profile* on the not-found screen when another profile exists | **pull** | +| `88577bb5` | 8 | 1 file, +282 | `MultipleProfilesRoundTripJvmTest`: two profiles, both ways, and a cold boot | **pull** | +| `29027f2b` | record | 2 files, +46 −7 | the built-record table in the plan; the README's reading order | **pull** | + +Nothing is dropped. The line is one feature and seven fixes braided together — four of +them live on Mantra today — and the +[first decision](#1-take-the-whole-line-and-what-taking-less-would-cost) is why the +braid is not unpicked. + +### What is a fix for Mantra today, before any second profile exists + +Four of the ten close bugs a Mantra install has now, with one profile, and each is +verifiable against the current tree: + +- **The relay observer leaks across sign-outs** (`9b3a1927`). + [RelaysSocketManager.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) + still reads `// TODO: Cancel all pending jobs?` above a map of one relay-list job per + pubkey, of which only the job for the pubkey being *started* is ever cancelled. Sign out + of a read-only identity, sign in as another, and two observers feed `updateRelayPools` + with the one pool following whichever list emitted last. The sign-out exits that make + this reachable arrived in the first pull. +- **A read-only identity's inbox stays closed after its nsec is pasted** (`759199f2`). + [NostrDao.kt:451](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt) + says of a wrap it cannot open that "the key that opens this one may be signed in later", + and nothing runs when it is: `storeNostrEvent` never re-indexes an id it holds, so the + wraps an npub preview fetched are never opened by the upgrade the first pull built. And + `GiftWrapSeal.giftWrapMessageId` is written on the NIP-17 DAO's path and not on the one + `indexNostrEvent` takes, so nothing in the database can say which wraps were opened — + the question `reopenInbox` asks. +- **Desktop can throw "multiple DataStores active for the same file"** (`2f420230`). + `Phoenix.jvm.kt` builds a `DataStoreManager` per call, and the library caches one + `GlobalPrefs` per process behind an unsynchronised check-then-set; the listing on IO and + a sign-in's write racing at first use both build one, and the loser throws. Upstream's + tests hit it one run in three. `JvmGlobalPrefs` is the one way the jvm target reaches + the prefs, under a lock; Android goes through the `Application`'s single instance and + never raced. +- **The startup screen's words** (`00fc9969`). `SovereignWalletStartupScreen` carries six + string literals — "Initializing...", "Preparing wallet...", "Opening wallet", "Starting + wallet" — outside the catalogue and in the wallet's vocabulary; the M3 audit counts them + among the 40 literals it reports without a budget, and after the pull it reports 30. + "Starting wallet" survives, in the one branch where a wallet is what is starting. + +The other three fixes are latent until a device holds two profiles — the precedence +`when` that puts "show me the list" above "open this one", the default that is read at +every boot and saved by nothing, *end this* wiping every profile's rooms from the screen +that adds one — and the line makes two profiles possible, so they ship with it. + +### What is a feature, and whether Mantra wants it + +The profile tab has had a *Change account* row since the first pull, and it routes to +`ImplementationPendingRoute` +([ActiveProfileScreen.kt:324](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)). +The switch behind it — `switchToWallet`, `resetToSelector`, the null identity sent to +startup with `popUpTo(0)` — has been in the tree since Phoenix and is reachable from +nowhere; Landing shows only when the device holds nothing, so a second profile cannot be +added. Mantra took the nsec and npub sign-in lines without qualification; this is the +line that answers the question both of them deferred, and it is wanted for the same +reason they were: a translator who reviews as one key and signs a chapter as another, or +who holds a collective's read-only preview beside their own profile, is the ordinary +case, and the row that promises it is already on the screen. + +The one feature choice inside the line that is a Mantra question rather than a Curare +one is that **a profile created from inside is a key, not a second wallet**. Mantra's +wallet is the Phoenix fork's; nothing in the translation product asks for a second +Lightning node, and a user who wants another profile has not asked for a second phrase +with funds behind it. The seed stays the first profile's, from Landing. That is the +right answer here too. + +## Four decisions + +### 1. Take the whole line, and what taking less would cost + +The alternative is to take the four fixes and leave the feature: `9b3a1927`'s observer, +`759199f2`'s inbox, `2f420230`'s `JvmGlobalPrefs`, `00fc9969`'s words. Measured +against the commits, that is not four cherry-picks; it is editing five commits by hand. +`9b3a1927` renames `switchToWallet` in the same hunk that stops the node; `759199f2`'s +owner column exists so that a *switch* cannot lose a message, and its `reopenInbox` +runs from the same activation collector; `2f420230` fixes the DataStore race in the +jvm actuals it rewrites for the bare-key writer; `00fc9969`'s strings are the +selector's. Every carve-out leaves a Mantra `SovereignWalletViewModel`, nav host and +startup screen that differ from upstream's in the same places, and the first plan's +principle stands: every feature left out is a permanent conflict seam, paid for at every +later pull. The removal of lines B, D and H was that principle's one exception, made on +product grounds and measured at 135 files of residual; this line touches none of it, +and adding a second exception for a feature the product wants would be paying the price +twice for nothing. + +So: all ten, in upstream's order, with the phases cut where upstream's own rollout +section says the line can ship in pieces. + +### 2. Schema version 20, and who allocates the number + +Two apps, two `applicationId`s, two databases that never meet on one device: nothing +stops Mantra from having a version 20 of its own shape and Curare another. What it would +cost is the method. The first plan's pull was mechanical because the nineteen schema +files were byte-identical on both sides; a Mantra 20 that is not Curare's 20 makes every +later migration a hand-merge of `MantraDatabase.kt`'s `autoMigrations` list and a renumbering +of every schema file after it, in both directions, forever. + +So the rule, stated once: **a schema version is allocated by whichever tree migrates +first, and the other tree pulls that migration before it adds its own.** Never two +different 20s. Today that is trivially satisfiable — Mantra has none pending; the check +that it stays satisfiable is a `git ls-tree` over every ref for the next number before +a migration is written on either side. + +Taking upstream's 20 verbatim is safe on the evidence: the columns are nullable +additions to two request-queue tables, `AutoMigration` with no data rewrite, and the +built tree regenerates nothing (`git status` clean under `composeApp/schemas/` after a +full compile; identity hash `4414c373…`). The downgrade story is the one every +migration in this database has had — an older build cannot open the file — and +`PlatformDatabaseBuilder` has the same answer it always had. The two-column change +carries no risk of its own; what it carries is the number, and the rule above is what +makes the number cheap. + +### 3. The one conflict is Mantra's, and it closes a loop + +The dry run's single conflict is `CreateProfileViewModel.kt` at `2f420230`, and it is +not the removal's seam. Mantra's `39fb64b6` moved the `nostrPublicKey()` import from the +app's `WalletManagerExtension` to the library's; upstream never took that change and +still imported the app's — one line of residual, the last of the first plan's Phase 6 +reverse-pulls that was never done. `2f420230` rewrites the import block wholesale, and +removes the seed generation the import served: the new view model asks a +`NewProfileWriter` for a key and derives nothing itself. + +Resolution: take the file as upstream leaves it. Not a union — the previous run's lesson +about joins holds for import blocks too — but *theirs*, because the base difference is +one import this commit deletes the use of. Afterwards the file is identical on both +sides, and the residual between the trees goes from 135 files to 134. The +[replay driver](./scripts/curated-replay.py) carries the rule as a `THEIRS` entry with +that reason, beside the one `42f3a697` needed for the same import in two other files. + +What is left of `39fb64b6` upstream after this: `WalletManagerExtension.kt` itself, +now with zero callers on either side. The reverse-pull the first plan owed shrinks to +deleting a dead file; see [Phase 6](#phase-6--close-the-loop). + +### 4. Two of the device's own profiles in one group + +Upstream's out-of-scope list names it and Mantra should name it louder, because groups +are Mantra's product. `ChatRoom`'s primary key is the group id and `userPublicKey` says +whose the row is; `DkgSession`, `GroupKeyState` and every FROST share hang off the room. +Today one device holds one profile and the collision cannot happen. After this line a +user can invite their second profile into their own collective, and the second member's +MLS state overwrites the first's — silently, in the shape of "the other device never got +it". The library, dialects and projects are keyed by `chatRoomId` and reached through +the room, so they scope per profile for free; the room itself does not. + +Decision: **not part of the pull, and a Mantra-native follow-up before the line ships +to users.** The cheap fix upstream names — refuse a Marmot welcome for a room the device +already holds under another profile, with a notice saying which — is one branch in the +inbound manager and a string. The real fix, keying the room by `(id, userPublicKey)`, +is a migration of every table that references a room, and it is not this plan's. Write +the cheap one natively, under Mantra's names, and offer it upstream by cherry-pick; it +is the kind of commit the fork should take. + +## How it lands + +The first plan's pipeline, unchanged in method, with three things that differ. + +**Rewrite from the fork point, not from the last pull.** The rewritten history is +rebuilt from `ba26c0b1` every time — 49 commits now, 103 seconds — because a +`filter-branch` over only `86cb876b..curated/curated` would leave the first new commit's +parent in Curare's names, and the cherry-pick of that commit would be a seven-hundred-file +rename. The normaliser has no new rules to learn: the rewritten tip holds no `curare`, +`Curare` or `com.it.curated` token outside the first plan's own prose, checked by +`git grep`. + +```bash +git fetch curated +git worktree add -b tmp/pull2 mantra # from 776455ec, the removal included +cd +git branch -f tmp/curated-src curated/curated +FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f -d \ + --tree-filter 'python3 /abs/path/docs/scripts/curated-unbrand.py tree .' \ + --msg-filter 'cat; echo "Pulled-From: curated/curated@$GIT_COMMIT"' \ + -- ba26c0b1..tmp/curated-src +python3 docs/scripts/curated-replay.py pick \ + a5ff2641 3008137e 9b3a1927 00fc9969 25464595 2f420230 759199f2 9bb34004 88577bb5 29027f2b +``` + +A clean worktree is required — `filter-branch` refuses unstaged changes, and a +populated `lightning-kmp-app` at a pin other than `84cc44c` counts. A fresh worktree +has no submodule and is clean; for the build afterwards, symlink a populated checkout +at `84cc44c` in (`git status` then shows ` T lightning-kmp-app`, which the ten never +touch) and pass `ANDROID_HOME` on the command line, as the first plan's appendix did. + +**The exactness check is restated**, because `git diff tmp/curated-src HEAD` is no +longer "the commits you dropped" — it is 135 files of the removal. The property that +holds instead: **the pull moves Mantra by exactly the ten commits, so the residual +between the trees is the same before and after**, file for file and hunk for hunk, +except where a resolution deliberately changed it. + +```bash +U0=; U1=; M0=776455ec; M1=HEAD +diff <(git diff --name-only $U0 $M0 | sort) <(git diff --name-only $U1 $M1 | sort) +# expected: exactly CreateProfileViewModel.kt, gone from the residual +for f in $(comm -12 <(git diff --name-only $U0 $M0 | sort) <(git diff --name-only $U1 $M1 | sort)); do + cmp -s <(git diff $U0 $M0 -- $f | grep '^[-+]' | grep -v '^[-+][-+]') \ + <(git diff $U1 $M1 -- $f | grep '^[-+]' | grep -v '^[-+][-+]') || echo "residual moved: $f" +done +# expected: nothing +``` + +Anything else in either list is a normaliser rule that is wrong, a resolution that is, +or an upstream commit that crossed the seam — and it is found here. + +**The rules the driver needs**: one. `2f420230` × `CreateProfileViewModel.kt` → theirs, +for the reason in the third decision. The `UNION` rule for `strings.xml`, +`MantraNavHost.kt` and `docs/README.md` stays in place and did not fire — git merged all +three cleanly at every one of the ten — but a future upstream commit that appends +strings where the removal deleted them will need it, and the first plan's lesson about +deletions is already encoded in it. + +Then the four checks, per phase, as before: `:composeApp:compileDebugKotlinAndroid`, +`:composeApp:compileKotlinJvm`, `:composeApp:jvmTest`, `:composeApp:testDebugUnitTest`, +`:composeApp:m3Audit`, and — new for this pull — `git status` under `composeApp/schemas/` +after the compile, which is the check that Room agrees with the committed `20.json`. + +## The phases + +Each phase ends the same way: both compilers, both test suites and the audit green on +the branch, the exactness check as above against the phase's cut, and one review of the +diff against the phase's stated contents. Upstream's rollout section says which pieces +can ship apart — Phase 1 alone, 2 with 3, 4 with 5, 6 whenever, 7 after — and the cuts +below follow it, so that each phase is also a shippable state. + +### Phase 0 — the base + +Nothing from Curare lands. Two things to settle first: + +1. **The removal is on `mantra` and not on `origin/mantra`.** `776455ec` is the local + `mantra` tip and `origin` is at `0fa807a6`. Every measurement in this document is + against `776455ec`; a pull onto `0fa807a6` would land the ten on a tree that still + holds lines B, D and H, and would then have to be removed from under them. Push the + removal, or merge it, before anything else. +2. **A worktree with the library at `84cc44c`.** The pin does not move in this line, so + there is no `PIN` entry to add; but a worktree whose submodule sits at another commit + (this session's was at `01489b8`, an older master) makes `filter-branch` refuse and + the compile fail on `NostrCredentialManager`. `git submodule update --init` fixes it + online; offline, fetch the commit from a populated sibling checkout — + `git -C lightning-kmp-app fetch /lightning-kmp-app 84cc44c && git -C lightning-kmp-app checkout --detach 84cc44c` + — since worktrees keep separate submodule git directories. + +Exit: `git status` clean in the scratch worktree; `origin/mantra` at `776455ec` or later. + +### Phase 1 — the dry run + +**Run on 2026-09-13, against `curated/curated` at `29027f2b`, on a scratch worktree of +`776455ec`.** The record, so the next reader starts from what happened: + +| measurement | value | +|---|---| +| `filter-branch` over `ba26c0b1..curated/curated` | 49 commits, 103 s; 834 files rewritten, 828 paths moved at the tip; 0 brand tokens left in code | +| files the ten touch | 69 (+10,184 −387; without `20.json`, 68 files +4,486 −387) | +| of those, already differing between the trees | 6: `strings.xml` (−234), `MantraNavHost.kt` (−264), `DatabaseNostrRepository.kt` (−6), `NostrRepository.kt` (−14), `CreateProfileViewModel.kt` (±1), `docs/README.md` (+7) | +| replay | 10 picked: 9 clean, 1 resolved by rule, 0 stuck | +| the resolution | `2f420230` × `CreateProfileViewModel.kt`: theirs | +| exactness | residual 135 → 134 files; the one gone is `CreateProfileViewModel.kt`; no other file's residual moved | +| `compileDebugKotlinAndroid` + `compileKotlinJvm` | clean | +| `:composeApp:jvmTest` | 868 tests, 0 failures (Mantra before: 826) | +| `:composeApp:testDebugUnitTest` | 420 tests, 0 failures (before: 413; the seven are `StartupChoiceTest`) | +| `:composeApp:m3Audit` | all budgets met; 12 adaptive uses, 2 navigation components; composable literals 40 → 30 | +| Room after the compile | `git status` clean under `composeApp/schemas/`; `20.json` byte-identical to the fork's | +| `strings.xml` | 444 → 455 strings; 14 added, 3 removed (`change_account`, `end_this`, `select_a_wallet`) | +| library pin | `84cc44c` before and after; the ten do not touch the gitlink or `.gitmodules` | + +If the upstream moves before Phases 2–5 land, redo this on the day: a number that +drifts is an upstream commit that crossed the seam, and the driver stops on it rather +than guessing. + +### Phase 2 — the credential, alone + +`a5ff2641`, `3008137e`. The plan and its first phase, on their own, because this is the +one commit in the line that **changes what is on disk**: the first launch after it writes +a `Secret` credential into `nostr-credentials.dat` for every seed the device holds, and a +build from before it, reached by a downgrade after that write, lists each seed-backed +profile twice — once as a wallet with a derived key, once as a bare key under a different +id. Nothing is lost, both rows sign as the same key, and the next upgrade lists them as +one; but it is the one line of this pull worth a release note, and shipping it first +means that a rollback of any later phase rolls back to a tree that already knows the new +file. + +What to look at in review: `writeMnemonic` writes the credential *before* the seed, so a +crash between the two leaves a profile the phrase completes rather than a seed with no +listing; `SeedCredentials.reconcile` runs after both files are read and hands the listing +what it returns, so a repair that could not write still lists the seed by derivation — a +failed write must never hide a wallet; `setActiveWallet` reads the key from the +credential and `check()`s it against the node's, since a node disagreeing with the +credentials file is the one corruption worth refusing to run under; +`forgetNostrCredential` answers `WalletAttached` for a key a seed derives, because the +next repair would write it back. + +Exit: a device with a seed lists once, from the credential, with the node's key as a +cross-check; `SeedCredentialsJvmTest` and the rewritten halves of `IdentityWriterJvmTest` +and `StoredIdentityJvmTest` green; release note drafted. + +### Phase 3 — the switch, the precedence, the switcher, the entrances + +`9b3a1927`, `00fc9969`, `25464595`, `2f420230`. Upstream ships 2 with 3 and 4 with 5; +they go together here because a switch nobody can reach and a switcher nobody can add to +are each half a feature, and the four are 53 files of which the one resolution above is +the whole of the conflict. + +What to look at: `observeActiveUserId` is a child of `collectLatest` and the per-pubkey +map is gone — the test that fails against the old observer is +`RelaysSocketManagerSwitchJvmTest`'s re-emission of A's list changing nothing; +`switchToIdentity` clears the identity *before* stopping the node, so every collector +that could reach the business is cancelled first; `StartupChoice.resolve` puts what a +switch or sign-in named above the user's earlier "show me the list"; `ProfilesScreen` +has a back button and one column at every width; `CreateProfileRoute(withWallet = true)` +only from Landing; the create view model writes the secret, then the six bootstrap +events, then calls the tail — the order the sign-in screen documents as load-bearing; +`JvmGlobalPrefs` is the only place the jvm target reaches `loadGlobalPrefsForWallet`. + +Exit: on desktop and Android, a second profile can be signed in or created from the +switcher and switched to; the previous node is stopped once; a cold boot opens the +last-used profile without asking; `IdentitySwitchJvmTest`, `ProfilesScreenJvmTest`, +`AddProfileFromInsideJvmTest`, `CreateProfileScreenJvmTest` green. + +### Phase 4 — the queue owner and the inbox, with the migration + +`759199f2`, alone, because it is the schema change and upstream's rollout says it can +ship early or late independently of everything else. It fixes a case Mantra has today — +the closed inbox of an upgraded read-only identity — and the case the switch would +create: a wrap fetched under the other profile's key, stored, and never opened again. + +What to look at: the `AutoMigration(from = 19, to = 20)` comment says what the columns +mean and that the broadcast queue deliberately has none; `DatabaseNostrRepository` +stamps where the caller stamped nothing and keeps an owner a caller named; the DAO's +own requests — placeholder-profile syncs, a Marmot join's participant syncs — stay +unowned on purpose, since they fetch public kinds; `reopenInbox` runs from the sync +pumps' collector once per activation, for an identity that can sign, *before* the live +inbox and the broadcast pump are launched; one transaction per wrap, so one that +cannot be opened rolls back its own changes and the next is still tried; +`GiftWrapMessage.toGiftWrapSeal` sets `giftWrapMessageId`. And the schema check: a +full compile, then `git status` — nothing under `composeApp/schemas/`. + +Exit: `OwnedRequestQueueJvmTest` and `ReadOnlyGiftWrapDaoJvmTest` green; an npub +signed in, sent a DM by someone else, then upgraded with its nsec reads the DM on the +next activation; +schema files 1–20 committed and regenerated identically. + +### Phase 5 — the exits, the round trip, the record + +`9bb34004`, `88577bb5`, `29027f2b`. Small, and the last: the not-found screen's third +action when another profile exists, the eight-phase round trip through the real view +models, and the built-record table at the top of `multiple-profiles.md` with the +README's reading-order sentence. + +What to look at: `MultipleProfilesRoundTripJvmTest` constructs no `GlobalPrefs` of its +own — the library's per-process cache means a test that builds a +`SovereignWalletViewModel` must read the default through the view model's instance, the +trap `00fc9969` recorded — and Mantra's own future tests inherit that rule. + +Exit: the full suite at the dry run's numbers or above; the audit green; the README's +table has the new row between the npub note and the jvm-target note. + +### Phase 6 — close the loop + +Five things, none of them a merge. + +1. **The follow-up in the fourth decision**, natively: refuse a welcome for a room the + device already holds under another profile. Before the line reaches users, not + after the first report. +2. **Prose that is now off by a removal.** `multiple-profiles.md` anchors three links to + line numbers — `MantraNavHost.kt:418` and `:746`, `DatabaseNostrRepository.kt:111` — + that are upstream's, in a file Mantra's removal shortened by 264 lines. The anchors + describe the tree *before* the line was built and were already approximate upstream; + either drop the numbers or re-point them, in the same commit as item 1 or alone. +3. **Reverse-pull what Mantra owes the fork**, shrunk by the third decision: + `WalletManagerExtension.kt` is dead on both sides and Curare can delete it; the Torch + retirement is unchanged from the first plan's list. Two small commits, both + hand-cherry-picks in the other repository. +4. **Offer item 1 upstream.** It is written under Mantra's names against a file both + trees hold identically, so it applies to the fork as an ordinary cherry-pick — the + direction this whole exercise exists to make cheap. +5. **The fork decision, restated with a second data point.** The first plan estimated + half a day per pull; this one cost the machine about ten minutes and the reading about an hour, + because the tooling was in place and the line crossed no seam. That is the good case. + The bad case is any upstream commit that edits `ChatRoomDetailScreen.kt` or the nav + host's identity block, and the driver will stop on it rather than resolve it. The + three options the first plan listed stand in the same order — converge the package + name, invert the relationship, or keep pulling — and nothing about this pull changes + which is best; it only shows that the third is affordable for as long as the fork's + work stays clear of what Mantra took out. + +## Risks, and the silent ones in particular + +**The write on first launch.** Phase 2's repair is the first time a pull has changed a +file in the node-data directory of an existing install. It is idempotent, one write for +however many seeds are missing, nothing at all on a device with none or one already +repaired, and a failed write is a result rather than a throw; the listing goes on with +what it read. Test it on a real install with a seed from before the first pull, not +only on the in-memory harness. + +**The downgrade double-listing** is a release note, not a bug, and Phase 2 shipping +alone is what keeps it to one line. + +**Room 20.** An older build cannot open the file; the same story as versions 2–19. +Anyone carrying a Mantra branch that adds a table must rebase onto this before numbering +it 20 — the second decision's rule, and the `ls-tree` check that enforces it. + +**iOS is unverified**, again. `stopPlatformBusiness` has an ios actual that calls +`BusinessManager.stopBusiness`, one line, compiled by nobody on linux; the ios target is +gated behind macOS. Build it on a Mac before believing it — and before it, the +first plan's `Config.xcconfig` change, which has still not been. + +**Every switch passes the social precondition gate.** Upstream names it as out of +scope: every `ProfileLoaded` routes through the onboarding gate, on every launch and so +on every switch. For Mantra, whose users will switch between a collective's preview and +their own profile more often than Curare's, this is the first thing a user will complain +about; the fix is one branch in `processLocalAccount` for an account the machine has +seen before, and it is Mantra's to write. + +**The per-process `GlobalPrefs` cache.** A test that constructs `SovereignWalletViewModel` +must not build a `GlobalPrefs` of its own, or the second instance over the same file +throws — recorded upstream, and true of every Mantra test written after this. + +**`strings.xml` loses three keys.** `change_account`, `end_this` and `select_a_wallet` +go; nothing on Mantra references them outside the files the same commits rewrite, and +the compile is the check. The `UNION` rule applies a deletion correctly since the first +plan's fix; it did not need to. + +**The upstream moves.** Between this document being written and the pull landing, +`curated/curated` may gain commits. Redo Phase 1; the driver stops on anything new. + +## Appendix — the residual, for the record + +The 135 files by which Mantra at `776455ec` differs from the rewritten `86cb876b`, and +which this pull leaves alone, so that the next reader can tell a seam from a surprise: + +| kind | count | what | +|---|---|---| +| Curare has, Mantra does not | 83 | 35 under `ui/composable`, 28 under `ui/view`, `nostr/curated/` (5), the seven `nostr/Group*` readings and `CuratedSuggestion` with their tests, `WalletManagerExtension.kt` | +| Mantra has, Curare does not | 4 | `docs/curated-to-mantra.md`, its two scripts, `NostrPublicKeyJvmTest.kt` | +| both have, differently | 48 | the logo's thirteen files and one `build.gradle.kts` comment; the seven places where Phase 0's prose about the protected names is worded for a tree that had no rebrand (`Relays.kt`, `SharedKeyDerivation.kt`, `ChillDkgRitualManager.kt`, `PlatformContext.jvm.kt`, `HomeScreen.kt`, `shared-key-derivation.md`, `material-design-conformance.md`); the removal's footprint of twenty-six — `m3-title-case.py`'s two sample titles, `strings.xml`, `MantraNavHost.kt`, `ChatRoomDetailScreen.kt` with its view model and state, `ChatMessage.kt`'s `applyInnerEvent` arms, `ProposedEvent` and its test, the four repositories, `ChatTranscript`, `ProfileAvatar`, `Member.kt`, `NostrEvent.kt`, `NostrEventDao`, `LocalChatRoom`, the broadcast screen, button, view model, state and two tests, `docs/README.md`; and, until this pull, `CreateProfileViewModel.kt` | + +After the pull: 134, the same list less one. diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md index 0836f46f..5b4d2a32 100644 --- a/docs/curated-to-mantra.md +++ b/docs/curated-to-mantra.md @@ -60,7 +60,9 @@ resolution is to drop it. The upstream moved before this landed: `curated/curated` went from `86cb876b` to `29027f2b`, ten commits for several profiles on one device, one of them the fork's first schema migration (version 20). They are the next pull, and the schema change is -why they get their own decision rather than a ride on this one. +why they get their own decision rather than a ride on this one — which is +[curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md), planned and dry-run the +same day. Written against `origin/mantra` at `ba26c0b1` and `curated/curated` at `86cb876b`; executed on 2026-09-13. diff --git a/docs/scripts/curated-replay.py b/docs/scripts/curated-replay.py index 945cd542..a1cfc686 100755 --- a/docs/scripts/curated-replay.py +++ b/docs/scripts/curated-replay.py @@ -2,7 +2,8 @@ """Land rewritten Curated commits on a Mantra branch, one cherry-pick at a time. The companion of curated-unbrand.py, and the second half of docs/curated-to-mantra.md's -pipeline as it was actually run. The rewrite (filter-branch with curated-unbrand.py as the +pipeline as it was actually run; docs/curated-to-mantra-profiles.md is the second pull it +served, and added the one rule that pull needed. The rewrite (filter-branch with curated-unbrand.py as the tree filter and a `Pulled-From: curated/curated@` trailer stamped by its message filter) leaves a branch `tmp/curated-src` in Mantra's names; this replays commits off it by their *original* sha, in the order you give, onto the current branch of . @@ -56,10 +57,12 @@ THEIRS = { ('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt'), ('42f3a697', 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt'), ('e3cc23ae', UI + 'KeyRecoveryScreen.kt'), + ('2f420230', 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt'), } THEIRS_WHY = { '42f3a697': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the call it served", 'e3cc23ae': 'the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it', + '2f420230': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the seed derivation that import served; the file is identical on both sides afterwards (docs/curated-to-mantra-profiles.md, the third decision)", } DELETE = {('55664cc7', 'composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt')} From 44324c902b1443a3d8ef722fea9aa390082bbd5e Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 00:09:02 +0200 Subject: [PATCH 42/71] docs: plan several profiles on a device, starting from what a profile is The switch already exists as a state transition -- switchToWallet, resetToSelector, a null identity sent to startup with popUpTo(0), every collector a child of collectLatest -- and is reachable from nowhere: Landing shows only when the device holds no identity, so a second can never be added, and the profile tab's "change account" is a pending route. This plan builds the two entrances and fixes what the transition gets wrong. Before either, it changes one rule the two sign-in plans share. A seed's nostr key is not written to the credentials file; it is derived from the words at listing and from the running node at activation, and the writers keep one key out of two files. That puts the wallet where the profile should be: the list is a merge of two files with opposite ideas of what a row is, and the node has to run for a profile to know its own key. Phase 1 makes a profile a credential and a seed a wallet attached to one -- the credential written when the seed is, repaired into the file for every seed already on the device, merge inverted to list credentials and attach seeds by pubkey, the identity's key read from the credential with the node's as a cross-check, and a second refusal on forget for a key a seed derives. The node still starts for a profile with a wallet attached, for the channel watcher rather than for the key; making it lazy is now one branch and is named as its own decision. The other decision is that a switch is a restart of the signed-in graph, not a swap under it: every route carries the key it was pushed for. That settles the switcher as a pushed screen behind one tap, the previous node stopped, the last-used profile as the one that opens on launch, and a profile added from inside switched to. Nine phases: the credential; the switch, with the relay observer that never cancelled its predecessor and the node that kept running; the startup precedence, which put "show me the list" above "open this one" and never saved a default; the switcher, showing the nostr profile rather than "Default name" and labelling a row with a wallet attached; the add rows, where a profile created from inside is a bare key and not a second wallet, and "end this" -- which wipes every profile's database -- goes; an owner on the two fetch queues and an inbox sweep on activation, because a gift wrap fetched under the other profile's key is stored and never opened again; the exits; a round trip; rollout, with the one downgrade that lists a seed's profile twice. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@a5ff264164aafe89c1eb7f0487db61e9e1f6d436 --- docs/README.md | 6 + docs/multiple-profiles.md | 1186 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1192 insertions(+) create mode 100644 docs/multiple-profiles.md diff --git a/docs/README.md b/docs/README.md index dacca806..f5cf622c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ silent, or a decision that looked arbitrary and was not. | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | | [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable | | [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 | | [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 | @@ -57,3 +58,8 @@ The npub sign-in note is a phased plan that has been built, and reads as that plan's out-of-scope note answered: it takes the `Identity` type and the sign-in machine as given and asks what an identity with no secret is for, before it asks how to build one; read its table of where the build chose differently first. +The multiple-profiles note is a phased plan that has not been built, and reads as +the third of the sign-in notes: it asks what happens when the device holds two +identities, and its first phase changes one rule the other two share — a seed's +key becomes a credential like any other — so read it with `StoredIdentity.merge`, +`SovereignWalletViewModel.switchToWallet` and the startup screen open. diff --git a/docs/multiple-profiles.md b/docs/multiple-profiles.md new file mode 100644 index 00000000..49cd6ef9 --- /dev/null +++ b/docs/multiple-profiles.md @@ -0,0 +1,1186 @@ +# More than one profile on a device + +How a user holds several profiles on one device, moves between them, and adds +another by signing in or by creating one — why a profile is a credential and a +wallet is a thing attached to one, and why the switch has to be a restart of the +signed-in app rather than a swap underneath it. + +Read this after [nsec-sign-in.md](./nsec-sign-in.md) and +[npub-sign-in.md](./npub-sign-in.md). It takes the `Identity` type, the two key +stores, the sign-in screen and the forget sequence as given, changes one rule the +two of them share, and asks the question both deferred without naming it: what +happens when the device holds two of them. + +**Not built.** Phases 1–8 are one commit each, in the order given; Phase 9 is the +rollout. The phases are written as reasoning rather than as a checklist, for the +reason the two sign-in plans give: the code reads better against the argument it +came from. + +## The vocabulary + +The user asks for "accounts". The code says *identity* (`Identity`, +`StoredIdentity`, `activeIdentity`), the storage layer says *wallet* +(`WalletId`, `WalletsSelector`, `select_a_wallet`, "Opening wallet"), and every +string a user reads says *profile* ("Create profile", "Sign out of this +profile?", "This profile is read only"). Three words, and they are not three +names for one thing. + +**A profile is a nostr key. A wallet is a different thing that a profile may have +attached to it.** The two live in different files for a reason both sign-in plans +spelled out: `seed.dat` holds twelve words, from which a Lightning node *and* a +nostr key are derived; `nostr-credentials.dat` holds a bare key, or a bare public +key, from which nothing else can be. The relationship runs one way — a seed +always yields a key; a key does not imply a seed — and the code has, until this +plan, drawn the line in the wrong place: a seed's key is *not written to the +credentials file*, so a seed-backed profile exists only as a derivation, and the +app has to start a Lightning node to find out who it is +([What actually blocks it](#what-actually-blocks-it)). + +This plan settles the model and the words together: + +- **The credentials file is the list of profiles.** Every profile the device can + sign as is a `Secret` entry in it, and every one it can only look at is a + `Public` entry. A seed contributes a wallet, and the wallet is *attached* to + the profile whose key it derives — matched by public key, and written as a + credential when the seed is, which is [Phase 1](#phase-1--the-credential-a-seed-always-implied). +- **The code keeps saying identity; the user reads profile; and *wallet* is used + only of a wallet.** *Account* is not used anywhere. The two Phoenix-era strings + the user can still see, `select_a_wallet` and `change_account`, become *choose + a profile* and *switch profile*; the startup screen's loading literals follow, + except the one that is genuinely a wallet starting. +- **No renaming of types.** `WalletId` keys every preference file in the library + and was left alone by both sign-in plans for that reason. A profile's id is its + wallet's when it has one — `hash160(nodeId)`, as it has always been for a + seed — and `hash160(pubkey)` when it has none, which is the rule the npub plan + set and this plan keeps, for the reason in the + [appendix](#one-id-space-for-every-profile). + +What the distinction decides, phase by phase: + +| the wallet is not the profile, so | where | +|---|---| +| a seed's key is a credential like any other, written when the seed is and repaired into the file for every seed already here; the identity's key comes from the credential, not the node | [Phase 1](#phase-1--the-credential-a-seed-always-implied) | +| a switch is between profiles; the node, where there is one, follows the profile it belongs to — stopped when that profile is left, never started for one that has none | [Phase 2](#phase-2--a-switch-that-tears-down-what-it-should) | +| the switcher says which profiles have a wallet attached, before the tap | [Phase 4](#phase-4--the-switcher) | +| a profile added from inside is a *key*, not a second seed: wanting another profile is not wanting another wallet | [Phase 5](#phase-5--adding-a-profile-from-inside) | +| a bare key can be forgotten; a key with a wallet attached has funds behind it, and its sign-out stays a wallet question | [Phase 7](#phase-7--leaving-one-profile-among-several) | +| a wallet attached to a profile that did not derive it, or one wallet every profile on the device pays from, is the model this leads to — and its own plan, which Phase 1 makes small | [Out of scope](#out-of-scope) | + +## The constraint + +One database, one process, one set of pumps, and every route on the stack +addressed by the key it was pushed for. + +Every screen from the home tab inward takes `activeUserPublicKey` as a route +argument — `HomeRoute`, `SearchRoute`, `ActiveProfileRoute`, `ChatRoomDetailRoute`, +all of them. The navigation component reads the signed-in key *off the current +route* rather than holding it +([MantraNavHost.kt:418](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)), +precisely so that it cannot drift from the screen underneath. That is the right +design for one profile and it decides the shape of a switch: a stack of routes +built for key A cannot be handed key B. The only honest switch is to clear the +stack and rebuild it for B — which is, as it happens, exactly what the app already +does when the active identity changes. + +The second half of the constraint is the database. It is one Room file for the +device, and it is *mostly* keyed by whose it is: `ChatRoom.userPublicKey` — whose +comment reads "should help us have multiple user support" +([ChatRoom.kt:48](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt)) +— `UnsignedNostrEvent.pubKey`, `MarmotKeyPackageBundle.publicKey`, +`GiftWrapMessage.receiverPublicKey`, the `Connection` relation by both keys. The +profile cache is shared, and should be: a kind 0 is the same event whoever fetched +it. What is *not* keyed by whose it is are the three request queues, and +[Phase 6](#phase-6--a-queue-per-identity-and-the-inbox-that-arrived-while-another-profile-was-open) +is about the one place that matters. + +## What is already built + +Most of a switch. The Phoenix fork this app grew from is a multi-wallet +application, and its switching machinery came across intact and unused. + +| piece | where | state | +|---|---|---| +| the credentials file, its encrypted writer, and the app writing it from `writeMnemonic` — to *delete* a public entry a seed supersedes | [IdentityWriter.kt:148](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt) | built; writing a `Secret` from the same place is one line more | +| `migrateFromNostrKeys`: a named, idempotent write that runs from `listIdentities` before anything reads either file | [SovereignWalletViewModel.kt:183](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; the shape Phase 1's repair copies | +| `StoredIdentity.merge`, deriving every seed's nostr public key at listing to compare stores by pubkey | [StoredIdentity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt) | built; the derivation is what attaches a seed to its credential | +| `switchToWallet(id)`: sets `desiredWalletId`, clears the active identity | [SovereignWalletViewModel.kt:304](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; called by the sign-in and create tails and the startup selector, never from a signed-in screen | +| `resetToSelector()`: clears both and asks startup for the list | [SovereignWalletViewModel.kt:315](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; the sign-out and forget tails use it | +| `NavigationViewModel.observeProfile`: a null identity is `StartupPhoenix`, which the nav host navigates to with `popUpTo(0)`; a new identity is observed from its account | [NavigationViewModel.kt:184](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built — this *is* the restart | +| the notary, the sync pumps and the live subscriptions run as children of `collectLatest` over the identity, so a change cancels them — the comments say "a wallet switch" in as many words | [NotaryViewModel.kt:62](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt), [SynchronizationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt) | built | +| the startup screen: lists, picks by `forceWalletId` / `desiredWalletId` / the default, shows `WalletsSelector` otherwise, and wraps every activation in the lock gate | [SovereignWalletStartupScreen.kt:106](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt) | built; the precedence is wrong for a switch, see below | +| `WalletsSelector`: a current section with a divider, the others below, a *read only* label, `topContent` and `bottomContent` slots | [WalletsSelector.kt:59](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | built; shows "Default name" and a random emoji for every row | +| `BusinessManager`: a map of running nodes by wallet id; starting one that runs returns it; `stopBusiness(walletId)` on all three platforms | [BusinessManager.kt:208](../lightning-kmp-app/library/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt) | built; nothing in the app calls `stopBusiness` | +| `GlobalPrefs.getDefaultWallet`, `saveDefaultWallet`, `clearDefaultWallet` | [GlobalPrefs.kt:102](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt) | read by startup; never written by anything | +| the sign-in and create tails: re-list, select, go to startup | [MantraNavHost.kt:746](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), [:545](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt) | built; a shape that works from inside the app as well as from Landing | +| *Change account* on the profile tab | [ActiveProfileScreen.kt:309](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt) | `ImplementationPendingRoute` | +| `ForgetIdentity` and `SignOutViewModel` for a read-only identity; *forget this key* for an nsec | `identity/ForgetIdentity.kt`, `NostrSecretScreen` | built; the tail lands on the selector or Landing, which is right for several profiles too | +| `NpubPreviewRoundTripJvmTest`: a real key store, a real in-memory database, real view models | `jvmTest/.../identity/` | the harness every test below reuses | + +So the switch exists as a state transition and is reachable from nowhere, and +the list it would switch between is drawn from two files that disagree about +what a profile is. That is the whole shape of this plan: fix what a profile is, +build the two entrances, fix what the transition gets wrong, and make the one +data problem it exposes go away. + +## What actually blocks it + +Seven things, in the order a user would hit them. + +**A seed's profile is not a credential.** `writeMnemonic` writes `seed.dat` and +nothing else — and if a `Public` entry for the seed's key exists, it *deletes* +it, so the seed becomes the only holder +([IdentityWriter.kt:148](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt)). +The key is then derived twice on every launch: at listing, from the words, to +learn the pubkey the selector shows; and at activation, from the *running node's* +key manager ([SovereignWalletViewModel.kt:151](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)), +which is why a seed-backed profile has to start Lightning before it can be +anyone. Both sign-in plans made this an invariant rather than an accident — one +entry per pubkey *across both files*, `writeNostrKey` refusing a key a seed +derives, `writeMnemonic` refusing a seed whose key is here as a secret — and it +is the wrong invariant: it says a wallet is a profile, and the switcher this plan +builds would inherit a list in which one kind of row is a key and another is a +seed pretending to be one. + +**There is no way in.** Landing is reached only when `availableIdentities` is +empty ([SovereignWalletStartupScreen.kt:95](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)), +and the sign-in and create screens are reached only from Landing. A device with +one profile cannot be given a second: the flow that adds one is behind a screen +the device never shows again. + +**There is no way across.** The selector is drawn by the startup screen and +nowhere else, and nothing in the signed-in app calls `resetToSelector()` except +the sign-out tails. The row that promises it on the profile tab routes to the +pending screen. + +**The startup precedence is wrong for a switch.** The `when` that picks what to +open reads `!startWalletImmediately -> null` *before* `desiredWalletId != null` +([:122](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)), +and `startWalletImmediately` is set to false by `resetToSelector()` and by the +lock prompt's back button and set back to true by nothing. So after the first +visit to the selector, every sign-in on that device lands on the selector instead +of the profile that was just signed in. And because `saveDefaultWallet` is never +called, `availableIdentities[defaultWallet]` is always null: a cold boot with two +profiles is the selector, every time, with no memory of which one was open. + +**Two things are not torn down.** `RelaysSocketManager.observeActiveUserId` keeps +one relay-list observer *per pubkey* and only ever cancels the one for the pubkey +being started; its own comment says `TODO: Cancel all pending jobs?` +([RelaysSocketManager.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt)). +After a switch, A's observer and B's observer both feed `updateRelayPools`, and +the one pool follows whichever relay list emitted last. And the previous +profile's node, where it had one, keeps running in `BusinessManager` — Electrum, +the LSP peer, the swap-in watcher — for a profile nobody is looking at. The nsec +plan established that nothing in this app reads the node but the key; a node +running for a profile that is not active is that finding with the sign flipped. + +**The queues belong to the device, not the identity.** `SynchronizeNostrEventRequest`, +`NegentropySynchronizeRequest` and `BroadcastNostrEventRequest` have no owner +column, and the pumps drain whatever is `pending` with the *active* key pair +([DatabaseNostrRepository.kt:111](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)). +Requests A queued a moment before the switch — its sign-in sync, its room +reconciliation — are answered while B is active, and everything that comes back +is indexed as B: a gift wrap addressed to A is stored, found not to be addressed +to the active key, and left ([NostrDao.kt:439](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +Then `storeNostrEvent` never indexes an event it already holds +([NostrDao.kt:176](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt), +the `TODO: Check if event got indexed`), so when A is opened again, its live +subscription re-receives the wrap, the DAO sees a known id, and returns. **A +message that arrives while the other profile is open is lost to the one it was +for.** Silently, and it looks in the logs like nothing happened, because nothing +did. + +**Two destructive controls assume one profile.** `CreateProfileScreen`'s *end +this* calls `wipeDatabase()` ([CreateProfileScreen.kt:381](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt)) +— on a device with a second profile, that profile's rooms and MLS state go with +it, from a screen the user opened to *add* one. And once a default is remembered, +forgetting the identity it names has to clear it, or the next boot opens a +profile the list no longer has. + +## The two decisions to make first + +### What a profile is + +Two models were on the table. + +**A seed is a profile by derivation.** What the code does: the credentials file +holds only bare keys, a seed's key is recomputed from the words at listing and +from the node at activation, and the writers refuse to hold the same key in both +files. One copy of every secret, nothing to migrate, and the two sign-in plans' +tests pass as written. The costs are the ones the blocker above lists: the node +has to run for the profile to know its own key, the list of profiles is a merge +of two files with opposite ideas of what a row is, and there is no place for a +wallet that belongs to a profile it did not derive. + +**A profile is a credential; a seed is a wallet attached to one.** Creating or +restoring a seed writes the seed *and* a `Secret` credential for the key it +derives; every seed already on the device gets its credential written at the +first listing; `merge` lists the credentials file and attaches each seed to the +entry it derives. The identity's key is read from the credential for every kind, +so the node stops being where a profile's key comes from and becomes what a +wallet is — which is the sentence this plan wants to be true, and the one that +makes "attach a wallet to this profile" a later plan instead of a redesign. + +**This plan takes the second.** Its costs are real and Phase 1 pays them: a +repair step at listing, the derived secret held in a second file under the same +keystore key, one of the sign-in plans' invariants inverted, and every `when` +over `IdentityKind` re-read for what the kind now means. What it does *not* do is +make the node lazy, though it makes that possible — the reason is under Phase 1, +and the decision is named under [out of scope](#out-of-scope). + +### What a switch is + +Is a switch a *restart* or a *swap*? + +**A swap** sets the new identity while the stack stands. Every route on that +stack carries the old key as an argument; every view model built from those +routes is observing the old key's rows; the notary and pumps restart under the +new key while the screens above them show the old one. Making that consistent +means every screen re-reads the identity from a flow instead of its route — the +snackbar-host problem again, in forty-three places — or a re-navigation that +amounts to a restart anyway. Rejected, and the [appendix](#a-live-swap-under-the-stack) +says so at more length. + +**A restart** is what `observeProfile` already does when the identity goes null: +`StartupPhoenix`, `popUpTo(0)`, the startup screen activates the next one, the +machine routes it from its account. Every screen is torn down; every collector is +cancelled and rebuilt; nothing built for A survives into B. It costs a visible +pass through the startup screen — "Opening profile", under a second for a bare +key, a little over one for a profile with a wallet attached — and it is honest +about what it is. + +**This plan takes the restart.** It is what the code does, it is the only design +that cannot leak one identity into another's screen, and it settles four things +that would otherwise each be an argument: + +- **The back stack is cleared by a switch**, so the control that switches is a + pushed screen reached by one deliberate tap from the profile tab — never a + global control in an app bar, a long press, or a swipe that could fire from + inside a half-written message. +- **The previous node is stopped.** It belongs to the profile being left, not + to the device; nothing reads it, the restart tears down everything else, and a + library that already handles "start one that exists" makes switching back + cheap enough. +- **The profile that opens on launch is the last one used.** A restart through + startup is also what a cold boot is, and the two should agree: `desiredWalletId` + for a switch, the saved default for a boot, the selector only when the user + asked for it. +- **A profile added from inside is switched to.** The sign-in and create tails + already select what they wrote; the one they left stays on the device. + +Everything below is downstream of those two words: credential, and restart. + +--- + +## Phase 1 — the credential a seed always implied + +**App only. No library change.** The file format, the encrypted writer and the +manager all exist; the app already writes the credentials file from the seed +writer. This phase changes what it writes there, and what the listing believes. + +### The write + +`writeMnemonic` writes two things, in this order: + +1. the credentials file, with a `Secret` for `keyManager.nostrPrivateKey()` under + its x-only public key — replacing a `Public` entry for that key if one is + there, which is the read-only upgrade the npub plan built, now landing on a + secret rather than on nothing; +2. `seed.dat`, as today. + +Credential first, for the reason the npub plan gave for the order it chose: a +crash between the two leaves a *bare-key profile* whose key the phrase derives, +which is a valid thing for the device to hold and which pasting the phrase again +completes. The reverse order would leave a seed with no credential, which the +listing below tolerates and the repair fixes — also recoverable, but the first +order says the model out loud: the profile exists, and then a wallet is attached +to it. + +`writeNostrKey` and `writeNostrPublicKey` are unchanged. The duplicate table +becomes: + +| the pubkey is already here as | pasting its nsec | pasting its npub | pasting its phrase | +|---|---|---|---| +| a secret with a wallet attached | `AlreadyExists` | `AlreadyExists` | `AlreadyExists` | +| a secret, bare | `AlreadyExists` | `AlreadyExists` | **attach**: the seed is written, the entry stays as it is, `Written` with the wallet's id | +| a public entry | upgrade to secret, one write | `AlreadyExists` | upgrade to secret and attach, two writes | +| nothing | `Written` | `Written` | `Written`, two writes | + +The one new cell is the phrase pasted over a bare key, which the nsec plan +refused as `SeedAlreadyExists`. It was a duplicate under the old model because +two files would have held one key; under this one it is a profile acquiring the +wallet that derives it, and it is the first attachment the app performs. The +profile keeps its key and every row keyed by its pubkey. Its id changes to the +wallet's, so its preferences and metadata are fresh, as a new wallet's are — the +backup flags reset, which is right: there is a new secret to back up. + +### The repair + +Every seed already on a device has no credential. `SeedCredentials.reconcile`, +beside `migrateFromNostrKeys` in `listIdentities` and shaped like it — a named, +idempotent write that runs before anything reads the merged list: + +```kotlin +object SeedCredentials { + sealed interface Result { + data class Written(val count: Int) : Result + data object NotNeeded : Result + data class Failed(val cause: Throwable) : Result + } + + /** + * Writes a Secret credential for every seed whose derived key has none, replacing a + * Public entry for that key where there is one. One write, or none. A failure is + * logged and the listing goes on: a seed that cannot be repaired into the file is + * still listed, with its key derived, by [StoredIdentity.merge]. + */ + suspend fun reconcile(phoenixGlobal, wallets: Map, credentials: Map): Result +} +``` + +It runs after the seed file is read and the nostr-keys migration has run, and +before the credentials file is read for the listing — so the listing sees the +repaired file. On a device with no seeds it does nothing; on a device already +repaired it does nothing; on the first launch after this phase it writes once. +`Failed` is logged, not surfaced: this is a repair of something the app can still +work around, and a listing that goes red because a background write failed would +be worse than the derivation it was replacing. + +### The listing + +`merge` inverts. Today the seeds are the list and the credentials are added to +it; now the credentials are the list and the seeds are attached to it. + +```kotlin +fun merge(wallets, credentials): Map { + val walletsByPubkey = wallets.values.associateBy { nostrPublicKeyOf(it.words) } + val merged = LinkedHashMap() + credentials.forEach { (pubkey, credential) -> + when (credential) { + is Secret -> { + val wallet = walletsByPubkey[pubkey] + val stored = if (wallet != null) mnemonic(wallet, credential.privateKey) else nostrSecret(credential.privateKey) + if (stored.nostrPublicKey == pubkey) merged[stored.id] = stored + } + is Public -> if (pubkey in walletsByPubkey) log.w { "public entry for a key a seed derives; the next repair upgrades it" } + else nostrPublic(pubkey)?.let { merged[it.id] = it } + } + } + // A seed the repair could not write a credential for. Listed as it always was, + // with the key derived from the words, so that a failed write never hides a wallet. + walletsByPubkey.forEach { (pubkey, wallet) -> + if (pubkey !in credentials) { log.w { "seed with no credential; deriving" }; mnemonic(wallet).let { merged[it.id] = it } } + } + return merged +} +``` + +`StoredIdentity.Mnemonic` gains the key: `Mnemonic(userWallet, privateKey)`, with +`nostrPublicKey` derived from the key and checked at construction against what +the words derive — a mismatch is corruption, and the entry is dropped with a log +line rather than listed under a key it cannot sign as. The derivation from the +words stays, because it is what matches a seed to its credential; the nsec plan +noted `SeedManager` had already built that key manager once, so this is the same +second derivation it was. + +`IdentityKind` keeps its three values and its doc comment changes: + +```kotlin +/** A key the device also holds a seed for. The key is a credential like any other; the seed is the wallet attached to it. */ +Mnemonic, +``` + +Every `when` over the kind is re-read in this phase for what the kind now means, +and none of them change: `KeyRecoveryScreen` offers the phrase for this kind +because the phrase is the wallet's backup and derives the key; `NostrSecretScreen` +refuses to forget it because a wallet is attached; the startup branch starts the +node because a wallet is attached. The meaning shifted under them and the answers +held, which is what a kind that names the *attachment* rather than the *source* +buys. + +### The activation + +`setActiveWallet(walletId, business)` becomes `setActiveWallet(stored: StoredIdentity.Mnemonic, business)` +and builds the identity from the **credential's** key: + +```kotlin +val derived = business.walletManager.keyManager.value?.nostrPrivateKey() +check(derived == stored.privateKey) { "the node derives a different nostr key than the credential holds" } +setActiveIdentity(Identity.signing(id = stored.id, kind = IdentityKind.Mnemonic, nostrPrivateKey = stored.privateKey, …, business = business)) +``` + +The check cannot fail on a file the repair wrote — it derived the key from the +same seed — and it is kept because a credentials file is now the thing the app +signs with, and a node disagreeing with it is the one corruption worth refusing +to run under. + +**The node still starts.** Not for the key any more: for what `startNewBusiness` +does besides — it registers the wallet's metadata, applies its Electrum and Tor +preferences, records the last-used build, and on Android schedules the channel +watcher that notices a force-close while the app is shut. A phrase pasted from a +real Phoenix wallet can have channels behind it, and a build that stopped +watching them to save a second at startup would be trading a spinner for funds. +With the key in the credential, making the node lazy is one branch in +`doLoadWallet` and a decision about who watches channels while it is not running; +it is named under [out of scope](#out-of-scope) and this phase does not take it. + +### The forget + +`forgetNostrCredential` refuses two things instead of one: a key that is not in +the file, as today, and — new — a key that a seed derives. The seed would +re-derive it and the next repair would write it back, so a forget that succeeded +would undo itself at the next launch. `ForgetNostrCredentialResult.NotACredential` +becomes `WalletAttached`, because that is now the only reason a signing profile +cannot be forgotten, and `ForgetIdentity.Outcome` follows. + +### The two plans that said otherwise + +Each of the sign-in docs carries a table of where the build chose differently +from the plan. Each gains one row at the bottom, pointing here: the +one-entry-per-pubkey rule across both files, superseded — a seed's key is a +credential, and the seed is attached to it. Their tests change in the same +places: `IdentityWriterJvmTest`'s "the npub of a key a seed derives is refused" +stays; its "a seed whose key is here as a secret is refused" becomes "…is +attached to it". + +### Tests + +`SeedCredentialsJvmTest`, through the jvm key store: two seeds and an empty +credentials file — `Written(2)`, two `Secret` entries under the keys the words +derive; run again — `NotNeeded`, and the file's bytes are unchanged; a `Public` +for one seed's key — upgraded to `Secret`, `Written(1)`. + +`StoredIdentityJvmTest`: a `Secret` whose key a seed derives lists once, as +`Mnemonic`, under the wallet's id, carrying the credential's key; a seed with no +credential lists as `Mnemonic` with a derived key; a `Secret` whose key no seed +derives is `NostrSecret`; a `Public` for a seed's key is dropped. + +`IdentityWriterJvmTest`: creating from a phrase writes both files and the +credential is a `Secret` for the derived key; the phrase of a bare key attaches — +`Written` with the wallet's id, the entry unchanged; the nsec of a key with a +wallet attached is `AlreadyExists`; forgetting a key with a wallet attached is +`WalletAttached` and changes nothing. + +And a mnemonic activation in the round-trip harness, which neither sign-in plan +wrote because both were about the other kinds: the identity's key equals the +credential's and the node's. + +--- + +## Phase 2 — a switch that tears down what it should + +**App only. No UI. Needs Phase 1.** The transition, made correct, with nothing +yet calling it from a screen. Its diff is the view model, one manager, and three +one-line actuals. + +### The call + +```kotlin +// SovereignWalletViewModel +/** + * Makes [id] the identity to open next and clears the active one, which is the + * whole of a switch: the navigation observer sends a null identity to startup, + * startup opens [id] through the lock gate, and the machine routes it from its + * account. Everything reading [activeIdentity] is cancelled by the null and + * rebuilt by the activation. + */ +fun switchToIdentity(id: WalletId) { + val previous = _activeIdentity.value + _desiredWalletId.value = id + startWalletImmediately.value = true + _activeIdentity.value = null + if (previous?.business != null) { + viewModelScope.launch(Dispatchers.IO) { stopPlatformBusiness(previous.id) } + } +} +``` + +`switchToWallet` is renamed to this; its three callers — the two tails and the +startup selector's `onWalletClick` — follow. `startWalletImmediately` is set +back to true because a switch is the user saying which one, and the flag was +only ever the user saying *show me the list*. Nothing set it back before because +nothing could switch. + +The order inside is deliberate. The identity is cleared **before** the node is +stopped: clearing cancels every collector that could reach the business, so the +stop finds nothing reading it. The stop is `BusinessManager.stopBusiness` behind + +```kotlin +expect fun stopPlatformBusiness(walletId: WalletId) +``` + +with an actual per platform beside `updateBusinessActiveInUI`, which has the +same shape and the same reason to be an `expect`: the manager is a per-platform +object. + +The test is `business != null`, not `kind == Mnemonic`. The question is whether +the profile being left has a node behind it, and today those are the same fact — +but the first is the fact, and the second is how it currently comes to be true. +`previous.id` is the wallet's id when a wallet is attached, which is why +`stopBusiness` can take it, and why a profile's id is its wallet's. For a bare +key there is nothing to stop and the branch is not taken; for a read-only profile +likewise. + +**The tail keeps its own `navigate`.** The sign-in tail both switches and +navigates to startup with `popUpTo(0)`, and so does the observer on +`StartupPhoenix`; two writers to the stack, one entry popped by the other. It +looks redundant and is not: `_navigationUIState` is a `StateFlow`, and a +`getAndUpdate` to a state equal to the current one emits nothing. The explicit +navigation is what guarantees the stack moves even on the day the state does not. +Leave both, and leave a comment saying so, because the next reader will want to +remove one. + +### The relay observer + +`observeActiveUserId` becomes what the pumps already are — a child of +`collectLatest`, cancelled by the next emission: + +```kotlin +private fun observeActiveUserId() = scope.launch { + activeIdentityStateFlow.collectLatest { identity -> + val pubkey = identity?.nostrPublicKey ?: return@collectLatest + relayRepository.observePublicKeyRelays(pubkey).collect { relays -> + updateRelayPools(relays.filter { it.type == "user" }.map { it.mapToRelayDTO() }) + } + } +} +``` + +The `observeRelayJobs` map goes; it existed to replace an observer per pubkey and +could only ever leak. A null identity closes nothing — the pool is shared by the +pumps that a null identity has already cancelled, and the next identity's list +replaces it through `changeRelays` as it does today. + +### What needs nothing + +Listed so that nobody adds a guard that already exists. +`SynchronizationViewModel`'s pumps and `LiveSubscriptionManager.observe` are +children of `collectLatest`; `hasRequeuedStaleBroadcasts` is once per process +*on purpose*, and its comment already names the wallet switch as the reason. +`NotaryViewModel` keys on the private key and restarts its four observers. +`MlsGroupCache` and `MarmotInboundManager`'s pending commits are keyed by room, +and a room belongs to one identity by its `userPublicKey`. `DataStoreManager` +caches preferences per id for the life of the process, which is right for a +switch and only wrong for a test that reuses a key across directories, as the +nsec plan found. + +### Tests + +`IdentitySwitchJvmTest`, in the round-trip harness beside `NpubPreviewRoundTripJvmTest`: +write an nsec A and an nsec B through the writers, list, activate A with a real +`NotaryViewModel` and `NavigationViewModel` on the same database. Queue an +unsigned kind 1 for A: it is signed. Switch to B, activate B the way startup +does: the machine lands on B's state. Queue an unsigned kind 1 for A: it is +**not** signed while B is active, and is when A is opened again. That is the +assertion a switch is for — the notary signs as whoever is open and nobody else. + +`switchToIdentity` on its own, with `stopPlatformBusiness` injected as a recorder +the way the writers are injected into `SignInToProfileViewModel`: called once +with A's id when A had a node; not called for a bare key; not called when nothing +was active. And a `RelaysSocketManager` test over a fake relay repository with two +pubkeys' lists: after the switch the pool holds B's relays, and a re-emission of +A's list changes nothing. + +--- + +## Phase 3 — which profile opens, on launch and after a switch + +**App. Needs Phase 2.** The startup screen learns to agree with the switch. + +### The precedence + +The `when` that picks what to open, lifted out of the composable's `remember` +into a function that can be read and tested, in `ui/view/state`: + +```kotlin +object StartupChoice { + /** Which stored identity startup opens without asking, or null to show the selector. */ + fun resolve( + force: WalletId?, + desired: WalletId?, + startImmediately: Boolean, + identities: Map, + default: WalletId?, + ): StoredIdentity? = when { + force != null -> identities[force] + desired != null -> identities[desired] // a switch or a sign-in said which + !startImmediately -> null // the user asked for the list + identities.size == 1 -> identities.values.single() + default != null -> identities[default] // the last one used + else -> null + } +} +``` + +Two rows move. `desired` goes above `!startImmediately` — it is the more +specific instruction and the one a switch relies on — and the `size == 1` row +drops below it, because when both are set they name the same thing and the order +only mattered for reading. Everything else is as it was. + +### The default + +`setActiveIdentity` saves what it activates: + +```kotlin +fun setActiveIdentity(identity: Identity) { + _activeIdentity.value = identity + viewModelScope.launch(Dispatchers.IO) { getGlobalPrefs().saveDefaultWallet(identity.id) } +} +``` + +Every activation goes through here — startup for all three kinds, and the +sign-in and create tails through startup — so the default is always the profile +most recently open, and a cold boot with two profiles opens it. The selector is +one tap away in Phase 4, and the lock prompt's back-to-selector button stays for +the case it was built for. + +The inverse: the two forget tails — sign out of a read-only identity, *forget +this key* for a bare key — call `clearDefaultWallet()` before `resetToSelector()`. +`resetToSelector` already nulls `desiredWalletId`; the default is the one memory +of the identity that would otherwise outlive it, and `identities[default]` on a +forgotten id is a selector anyway, but a null read is better than a miss that +happens to be handled. + +### The words + +The five literals the startup screen shows — "Decrypting...", "Initializing...", +"Preparing wallet...", "Opening wallet", "Starting wallet" — move to the catalogue +in this phase because the phase rewrites the `when` they sit in. Four of them say +"wallet" about something that is not one and become *preparing profiles* and +*opening profile*. The fifth is right as it is: "Starting wallet" is shown from +`StartupViewState.StartingBusiness`, which only the branch with a wallet attached +reaches, and what is starting there is a Lightning node. It stays *starting +wallet*, so that a user with a bare-key profile never sees the word and a user +with a wallet sees it at the one moment it is true. The selector's title, +`select_a_wallet`, becomes `choose_a_profile`. The lock prompt's "coming soon" +literal stays; it is a stub and marked as one. + +### Tests + +`StartupChoiceTest` in `commonTest`, one case per row and the two orderings this +phase exists for: `desired` set and `startImmediately` false opens the desired +one; one identity and `startImmediately` false shows the selector, because the +user asked. `IdentitySwitchJvmTest` gains: after activating B, `getDefaultWallet` +is B's id; after forgetting B, it is empty; a fresh `SovereignWalletViewModel` +over the same store and a `StartupChoice.resolve` with its list and default +answers A. + +--- + +## Phase 4 — the switcher + +**App. Needs Phases 2 and 3.** One pushed screen, and the startup selector +brought up to match it. + +### The screen + +`ProfilesRoute`, reached from the profile tab's row — *switch profile*, where it +said *change account* — and drawn by `ProfilesScreen`: a `TopAppBar` titled +*profiles* with `NavigateBackButton` in the leading slot, and `WalletsSelector` +as its whole content, with `activeWalletId` set so the current profile sits above +the divider and the others below. + +Tapping another row is `switchToIdentity(id)` and nothing else — the observer +restarts the app as that identity, through its lock gate. Tapping the current row +does nothing; `canEdit` stays false, the edit dialog behind it is a stub. The +`bottomContent` slot holds the two rows [Phase 5](#phase-5--adding-a-profile-from-inside) +adds. + +Four states, said so. *Loading* while `listWalletState` is `Init`, which in +practice is never seen — the list was read at startup — but the state exists and +the screen says what it is waiting for. *Loaded* is the list. *Error* is +`ListWalletState.Error`, through `ErrorState` with `onRetry = listIdentities`, +because an unreadable credentials file is exactly the failure a retry can answer. +*Empty* cannot happen while something is signed in and the screen does not +pretend it can. `ScreenStateTransition` wraps the `when`; `readableContent()` on +the root; one column at every width, since a list of a handful of rows has no +detail to pair with. + +### What a row shows + +`WalletsSelector` shows `metadata.nameOrDefault()` — "Default name", for every +row, because nothing in this app writes the wallet metadata's name — over a +random emoji ([WalletsSelector.kt:162](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt)). +Two profiles side by side, both called Default name, distinguished by a bech32 +string, is not a switcher. + +The row takes the nostr profile instead: `WalletsSelector` gains a +`profiles: Map` parameter, and the row shows +`humanReadableNameOrPubkey()` over `ProfileAvatar(publicKey, profile)` — the +widgets the profile tab already draws itself with — falling back to the npub and +the metadata's emoji for a profile the device has no kind 0 for yet: one just +created, or a read-only one that was never found. `ProfilesScreen` collects the +map through `observeProfileWithPublicKey` for every listed pubkey, in a +`ProfilesViewModel` that owns the four states above. The *read only* label stays +where it is and means what it meant. + +And a second label of the same kind, *wallet*, on a row with a seed attached. +What the device holds for a profile — nothing, a key, or a key and a wallet — is +the one thing the switcher knows that the profile tab never shows. It is the +difference between a profile that can be signed out of and one whose sign-out is +a wallet question, and between two profiles with the same name, and it belongs +before the tap for the reason the read-only label does. A bare-key profile +carries no label; it is the plain case. + +The startup screen draws the same widget and gets the same map: `MantraNavHost` +already holds `databaseNostrRepository` where it composes +`SovereignWalletStartupScreen`, and passes it through. Both lists then look the +same, which is what a user who has just seen one and is now looking at the other +expects. + +### Tests + +`ProfilesScreenJvmTest`, with `runDesktopComposeUiTest` and the unmerged tree, as +`ReadOnlyEntrancesJvmTest` does: two identities, A current — A's name is above the +divider and B's below; tapping B invokes `onSwitch(B.id)` exactly once; tapping A +invokes nothing; a read-only B carries *read only*, an A with a seed attached +carries *wallet*, and a bare key carries neither; a B with no profile row shows +its npub. And `ActiveProfileScreen` under `LocalCanSign` true and false both have +*switch profile*: a read-only identity can leave for another profile the same way +a signing one can. + +--- + +## Phase 5 — adding a profile from inside + +**App. Needs Phase 4.** The two entrances, and the two things that were only +safe with one profile. + +### The rows + +Under the list, in `bottomContent`, on the switcher and on the startup selector +alike: + +| row | goes to | why not Landing | +|---|---|---| +| *sign in with a key* | `SignInRoute` | Landing has no back button and clears the stack on its way out: it is the first-run screen, and pushing it from inside would make it a pushed screen on some days and a root on others. The sign-in screen has `NavigateBackButton` already | +| *create a new profile* | `CreateProfileRoute` | same; and its tail is the one the create flow uses today | + +Landing stays exactly what it is. The caption on each row says what the tails +below make true: *the profile you are in stays on this device.* + +### The sign-in tail + +Unchanged in shape — `listIdentities { switchToIdentity(id); navigate(Startup) { popUpTo(0) } }` +— and now correct from inside: Phase 2 stops the previous node and Phase 3 makes +startup open the id it was handed. A read-only identity upgrading itself through +the Messages tab's *sign in with the nsec* is the same tail with the same id, and +keeps working; a bare key acquiring its wallet through a pasted phrase is the +same tail with the wallet's id. + +One thing changes. `IdentityWriter.WriteNostrCredentialResult.AlreadyExists` is a +`data object` and says only that the key was refused. From Landing that was the +whole message; from inside, "this key is already on this device" is a sentence +with an obvious next step, and the writers know which id they collided with — +the credentials map by pubkey, and the attached seed's wallet id where there is +one. It becomes `AlreadyExists(val id: WalletId)`, +`CredentialProblem.AlreadyOnThisDevice` carries it, and the sign-in screen's +error state gains one action, *switch to it*, which is `switchToIdentity(id)` and +the same tail. The recovery-phrase writer's `SeedAlreadyExists` maps the same way. +Two writers, one distinction, and a duplicate paste becomes the fastest switch in +the app. + +### A profile created from inside is a key + +`CreateProfileScreen` generates twelve words behind its form +([CreateProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt)): +a seed, a node, a wallet, and — at the NIP-06 path — the key that becomes the +profile. That is the right thing for the first profile on a device, which is the +one the wallet will belong to, and the wrong thing for the second: a user who +wants another profile has not asked for another Lightning node, another set of +channels, or a second twelve-word phrase with funds behind it, and would not thank +the app for handing them one. + +From the switcher, *create a new profile* makes a **bare key**: thirty-two random +bytes as a `PrivateKey`, written through `writeNostrKey` — the writer the sign-in +screen uses for a pasted nsec — then `createNewProfile(pubkey, name, bio)`, the +six-event bootstrap a new key gets, then the tail. Same screen, same form: +`createAccount` already takes its writer as a function, so the nav host passes +the seed writer from Landing and the key writer from the switcher, and the +screen does not know which it was given. Under this plan's model the two writers +differ by exactly one thing — whether a wallet is attached to the profile they +make — which is what the model was for. The declaration and confirmation copy say +"profile" and "keys" and are true of both. The backup lives where it does for a +signed-in nsec: the key recovery screen already shows the secret for that kind, +and the backup flags are per id, so a profile made this way is reminded to back +up its key the way a seed's profile is reminded about its phrase. + +The previous identity's notary observes `observeUnsignedNostrEvents(publicKey = +its own)`, so it never sees the new key's events; they wait for the new +identity's notary, which starts when startup activates it — at once, since there +is no node to start. Nothing crosses. The tail gains the `popUpTo(0)` the sign-in +tail has, so that back from the new profile's startup does not return to a form +whose key is already on disk. + +A second *wallet* is not offered from inside. Restoring one is the sign-in row — +a pasted recovery phrase brings its wallet with it, and the confirm step already +says so — and creating a fresh one waits for a wallet feature to want it, which +is named under out of scope. Landing's *create profile* keeps making a seed; if +the product decides the first profile should be a bare key as well, that is the +same writer swapped in one more place. + +**And *end this* goes.** It wipes the database — every profile's rooms, MLS state, +key packages and queues — from a screen whose purpose is to add a profile, and it +was only ever a development exit. Remove the button and the string; +`wipeDatabase` stays on the repository for the tests that use it. If a +device-level reset is ever wanted, it is a settings screen with a confirmation +naming every profile it will take, and not this. + +### Tests + +`SignInToProfileViewModelJvmTest`: the nsec of a key already here fails with the +id it is already here under, for each of the three writers. +`AddProfileFromInsideJvmTest`, in the round-trip harness: A active; B's nsec +committed through the sign-in view model; list — two entries; switch; B active; +switch back — A active, and `getLocalAccounts()` holds two accounts, each with its +own kind 0 and neither with the other's. A profile created from inside through +the key writer lists as `NostrSecret`, its account holds the six bootstrap rows, +and `BusinessManager.businessFlow` is empty afterwards — no node ran, the +assertion the nsec round trip already makes. `CreateProfileScreen` composed has +no node with the text *end this*. + +--- + +## Phase 6 — a queue per identity, and the inbox that arrived while another profile was open + +**App. Needs nothing above it to compile; Phase 2 to matter.** The one data +problem, closed from both ends. + +### Whose request + +The two queues that *fetch* gain an owner, because what a fetch brings back is +opened with the fetcher's key. The one that *sends* does not, because a signed +event is anyone's to carry: the signature is the author's whoever puts it on the +wire, and holding A's outgoing message until A is opened again would be worse +than sending it as B. A read-only identity does not run the broadcast pump at all +and the npub plan's rule stands. + +```kotlin +// SynchronizeNostrEventRequest, NegentropySynchronizeRequest +/** The identity that asked. Null for rows written before there was one to record, which any identity may drain. */ +val ownerPublicKey: HexKey? = null, +``` + +Room database version 20, an `AutoMigration` with a comment in the list the way +every nullable addition before it has one: rows written before it read back null, +meaning "the device's", which is what they were. + +**The stamp is the repository's, not the call site's.** Eighteen view models and +two DAO paths queue requests, and every one of them does so *as the active +identity* — a screen cannot queue anything as anyone else. `DatabaseNostrRepository` +takes `activeIdentity: StateFlow` at construction, which the nav host +has in hand where it builds the repository, and `queueSynchronizeNostrEvent` and +`queueNegentropySynchronizeRequest` stamp `ownerPublicKey = activeIdentity.value?.nostrPublicKey` +on every row. The DAO's own requests — the placeholder-profile syncs it plants +while indexing — are stamped from the `activeKeyPair` it is already handed. No +call site changes, and no call site can forget. + +The pumps then ask for their own: + +```sql +SELECT * FROM SynchronizeNostrEventRequest +WHERE status = 'pending' AND (ownerPublicKey = :publicKey OR ownerPublicKey IS NULL) +ORDER BY createdAt ASC LIMIT 1 +``` + +`observePendingSynchronizeNostrEventRequests(publicKey)` and its negentropy twin +take the pubkey the pump already builds its key pair from. A's requests wait for +A; B never opens a wrap that was fetched for A, because B never fetches it. + +### The inbox, reopened + +The other end: wraps that *were* fetched under the wrong key — before this +migration, or by a read-only identity whose nsec was pasted later, the case the +npub plan's DAO guard left with the words "the key that opens this one may be +signed in later" and no code behind them. + +```kotlin +// NostrDao +/** + * Opens every gift wrap addressed to [activeKeyPair] that the device stored but + * could not open at the time -- because another identity fetched it, or because + * this one held no key yet. One pass: wraps do not depend on one another, so a + * wrap that cannot be opened now will not be helped by opening its neighbours. + */ +open suspend fun reopenInbox(activeKeyPair: KeyPair): Int +``` + +The query is every `GiftWrapMessage` with `receiverPublicKey = me` that no +`GiftWrapSeal` names as its `giftWrapMessageId`; the body is the unseal branch of +`indexNostrEvent` from the `isAddressedTo` check down, lifted into an +`openGiftWrap(giftWrapMessage, activeKeyPair)` that both call. A Marmot welcome +inside one of them reaches `MarmotInboundManager` the way it would have on the +day; a direct message is filed as one. + +It runs once per activation, from `SynchronizationViewModel`'s collector, inside +`if (identity.canSign)` and **before** the live subscriptions and the broadcast +pump are launched — so that the sweep and the live inbox are not opening the same +wrap at the same moment. Both are idempotent by event id, so the order is about +not doing the work twice rather than about correctness. + +### Tests + +`ReadOnlyGiftWrapDaoJvmTest` has the fixture: a wrap addressed to A, stored +through B's key pair — event and wrap rows, no seal. `reopenInbox(A)` returns one, +and afterwards the seal and payload are stored and the message is filed; +`reopenInbox(A)` again returns zero and changes nothing; `reopenInbox(B)` returns +zero. A DAO test for the filter: A's pending request is not emitted to B's pump, +a null-owner row is emitted to both, and A's is emitted to A's. And the +repository stamp: a request queued while A is active carries A's pubkey, while +nothing is active carries null. + +--- + +## Phase 7 — leaving one profile among several + +**App. Needs Phases 3 and 4.** Small, because the forget sequence was built +right. + +Sign out of a read-only identity and *forget this key* for a bare key both run +`ForgetIdentity` and then the tail `listIdentities { resetToSelector() }`, which +shows the selector with what remains or Landing if nothing does. That is the right +behaviour with several profiles as well; Phase 3 added `clearDefaultWallet()` to +it, and this phase adds nothing else to the sequence. A profile with a wallet +attached answers `WalletAttached` from Phase 1 and is not offered either exit. + +Two exits learn about the others: + +- **The not-found screen** offers *try again* and, for a read-only identity, *use + a different key*, which forgets it. With other profiles on the device a third + action makes sense and costs one row: *switch profile*, pushing `ProfilesRoute`. + The identity that was not found stays listed; forgetting it remains a separate + decision. +- **Sign out for a profile with a wallet attached** stays `ImplementationPendingRoute`, + for the reason both sign-in plans gave: removing a seed is a wallet question, + and now — since the credential would come back at the next repair — it is + also the only way that profile *can* leave. What changes is that the user is no + longer stuck behind it: *switch profile* is the row above. + +### Tests + +`ForgetIdentityJvmTest` gains: forgetting B while A remains leaves A listed and +the default cleared; forgetting a profile with a wallet attached is +`WalletAttached`. `UnsyncedProfileScreen` composed with two identities has *switch +profile*; with one, it does not. + +--- + +## Phase 8 — the tests that actually prove it + +One round trip, beside the two that exist. + +**Two profiles, both ways, and a boot.** Create A from a phrase — both files +written, A listed once with a wallet attached; write B's npub; list; activate A; +sign B in from inside through the sign-in view model; the tail switches; B is +active and read-only, A's node was stopped and B's never started; +`NavigationViewModel` lands on B's state; switch back; A's state and A's node +again. Queue a kind 1 for A while B is open — not signed; open A — signed. Create +C from inside: a `NostrSecret`, active, with no node. Forget B and C; one +identity, default cleared. Then a *fresh* `SovereignWalletViewModel` over the +same directory, which is what a cold boot is: the repair finds nothing to do, it +lists A, and `StartupChoice` resolves A without asking. + +And `./gradlew :composeApp:m3Audit`: two new screens, a new route, and eight new +strings are where a `16.dp`, a "Switch Profile" and an untriaged +`contentDescription` arrive. + +--- + +## Phase 9 — rollout + +**No library change.** `BusinessManager.stopBusiness` exists on all three +platforms, `saveDefaultWallet` has been in `GlobalPrefs` since Phoenix, and the +credentials file's format already holds what Phase 1 writes into it. This plan +owes no tag, which is the first of the three sign-in plans that can say so. + +**Phase 1 ships alone, and first.** It changes what is on disk: the first launch +after it writes a credential for every seed on the device. A build *before* it, +reached by a downgrade after that write, lists each seed-backed profile twice — +once as a wallet with a derived key, once as a bare key under a different id — +because the old `merge` kept a secret for a seed's key and trusted the writers to +have prevented it. Nothing is lost: both rows sign as the same key and share +every row keyed by it, and the next upgrade lists them as one. It is the one +line of this rollout worth a release note. + +**Phases 2 and 3 ship together.** A switch without the precedence fix lands on +the selector instead of the profile it was asked for — not broken, but it is the +thing the phase is for. Phase 3 without Phase 2 is a startup screen that +remembers a default nobody can change without it. + +**Phases 4 and 5 ship together.** A switcher with no way to add a profile is half +a feature; the add rows without the switcher leave a newly added profile reachable +only through startup, which is the state the device is in today with two. + +**Phase 6 ships whenever it is ready, and should ship early if it can.** It is a +Room migration and a DAO change, and it fixes a case that exists *today*: a device +with two identities, switched at the startup selector a moment after one of them +signed in. The migration is a nullable column, which Room generates and an older +build cannot open — the same downgrade story every migration in this database has +had, and `PlatformDatabaseBuilder` has the same answer. + +**Phase 7 can follow by a release.** Without it, the not-found screen has two +exits instead of three and a wallet-attached profile's sign-out row is the same +pending route it is now. + +**Old builds.** A build before Phase 3 ignores the saved default and shows the +selector, as it does today. A build before Phase 6 drains every queue as whoever +is open, as it does today. Apart from the double listing above, nothing written +by this plan is misread by a build that predates it; it is only not read. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | the credential written and repaired, `merge` inverted, the activation, the forget guard | 1.5–2 | — | +| 2 | the switch, the node stop, the relay observer | 1 | 1 | +| 3 | the precedence, the default, the words | 0.5–1 | 2 | +| 4 | the switcher screen, the profile on the row | 1 | 3 | +| 5 | the add rows, a key-only create, the duplicate that switches, *end this* | 1–1.5 | 4 | +| 6 | the owner column, the pump filter, the inbox reopened | 1.5 | — | +| 7 | the exits | 0.5 | 3, 4 | +| 8 | the round trip | 0.5 | all | +| 9 | rollout | — | all | + +**Roughly a week and a half**, with Phase 6 in parallel from the start if two +people are on it. Two places carry the uncertainty. Phase 1 is where the two +sign-in plans' tests are re-read against a rule they were written to enforce, and +a test that turns out to have been asserting the old model somewhere unexpected +is a test to rewrite, not to delete. Phase 6's unseal branch of `indexNostrEvent` +has never been called from anywhere but `indexNostrEvent`, and lifting it out is +where a transaction boundary will turn out to have been load-bearing. + +## Out of scope + +- **A lazy node.** Phase 1 puts the key in the credential, so a profile with a + wallet attached could be activated without starting its node and start it when + a wallet feature asks. Not done here, for the reason under Phase 1: the node is + also where the channel watcher is scheduled, and a restored Phoenix phrase can + have channels. Its own decision, one branch in `doLoadWallet`, and the nsec + plan's "start the node lazily" now has nothing in front of it. +- **A wallet attached to a profile that did not derive it**, or one wallet on the + device that every profile pays from. Phase 1 makes attachment a fact about the + listing — a seed matched to a credential by pubkey — and both sign-in plans + deferred what it would mean to attach by *choice*. `Identity.business` is the + seam, the identity would carry the wallet's id beside its own, and Phase 2's + stop reads `business` rather than the kind so that the switch does not have to + be rewritten when it lands. +- **A second fresh wallet from inside.** Reachable today only through a pasted + recovery phrase. Until a wallet feature exists there is nothing to want it + for. +- **Showing the nsec of a profile with a wallet attached.** Trivially possible + after Phase 1 — the key is in the file — and a real want, for a user taking + their profile to another client. A row on `KeyRecoveryScreen` beside the + phrase; its own small change, with the phrase's warnings rewritten for a + secret that has a wallet behind it. +- **Sign out for a profile with a wallet attached.** Unchanged from both sign-in + plans, and now the only way such a profile can leave the device. +- **Two of the device's own profiles in one Marmot group.** `ChatRoom`'s primary + key is the group id and `userPublicKey` says whose the row is; a second local + member of the same group would overwrite the first's MLS state. Today it cannot + happen — one profile per device — and after this plan it can, by inviting + yourself. Refusing the invite at the join is the cheap fix; keying the room by + `(id, userPublicKey)` is the real one and is a migration of every table that + references a room. Named here so that the first report of it is not a mystery. +- **A database per identity.** The only design that isolates the queues, the + cache and the collision above wholesale. Rejected below, but it is the shape + the collision would eventually force. +- **The social precondition gate.** Every `ProfileLoaded` routes through it, on + every launch, and so on every switch. It is an onboarding screen shown to + people who have finished onboarding; a machine that skips it for an account it + has seen before is one branch in `processLocalAccount`, and its own decision. +- **Per-profile lock, per-profile notifications.** The lock prompt is a stub and + the app has no push. Both are per-id already in the preferences and would slot + into the activation this plan routes everything through. +- **Editing a profile's local name and avatar.** The metadata dialog is a stub, + and after Phase 4 the row shows the nostr profile, which is the name. +- **Tightening secrets in memory; NIP-46; NIP-49.** Inherited, unchanged. Phase 1 + adds one copy of a secret the device already held in derivable form, under the + same keystore key, and the decrypt-at-activation both plans deferred applies + to it as to the others. + +## Appendix — what was considered and rejected + +### A seed's profile by derivation only + +The model the code had, and the one this plan's first draft built on: the +credentials file holds only bare keys, a seed is a profile because its key can +be derived, and the writers keep one key out of two files. It needs no repair +and holds every secret once. It was rejected because it puts the wallet where the +profile should be: the list of profiles is a merge of two files with opposite +ideas of what a row is, the node has to run for a profile to know its own key, +and a wallet can never belong to a profile it did not derive. The cost of +changing it — Phase 1 — is a day and a half; the cost of building a switcher on +it and changing later is that switcher, twice. + +### One id space for every profile + +Under the model this plan takes, a profile could be keyed by `hash160(pubkey)` +whether or not a wallet is attached, with the wallet's `hash160(nodeId)` carried +beside it. Cleaner on paper, and rejected: the library keys the node's +preferences and metadata by the wallet's id inside `startNewBusiness`, so a +seed-backed profile would have two preference files — one the app writes, one +the node reads — and every existing device would need its metadata and backup +flags copied from the wallet's id to the profile's. A profile's id being its +wallet's when it has one keeps every file where it is, keeps `stopBusiness` and +`updateBusinessActiveInUI` one argument, and costs only what the npub plan already +accepted for the read-only upgrade: a profile that acquires a wallet gets fresh +preferences. + +### A live swap under the stack + +Set the identity and keep the screens. Every route carries the old key, every +view model was built from a route, and the navigation component reads the key off +the current route by design. Either every screen re-reads the identity from a +flow — the parameter-in-forty-three-lists problem the snackbar host and the +signing capability were both invented to avoid — or the stack is rebuilt, which +is the restart. The restart also gets the lock gate for free: an identity is only +ever activated through `LoadWallet`. + +### A global switcher control + +The avatar in the app bar, as Google's apps do it; a long press on the profile +tab; a swipe on the navigation bar. The home app bar lost its avatar by an +information-architecture decision recorded in `TopLevelDestination`, and putting +one back for a different purpose is two meanings on one control. A long press is +undiscoverable. And every one of them fires a `popUpTo(0)` from wherever the user +is — a switch is one deliberate tap on a screen that says what it does, and the +profile tab's row is where the app has been promising it. + +### A bottom sheet instead of a screen + +The other common shape, and the app has a `ModalBottomSheet`. Rejected for +consistency rather than on merit: the startup selector is a screen, both lists +are the same widget with the same rows, and the add rows push screens anyway. A +sheet that pushes screens from under itself is a screen with a worse back story. + +### Keeping the previous node running + +The cheaper switch-back — `startNewBusiness` returns a running node in +milliseconds. But nothing reads the node except the key, and after Phase 1 not +even that; the restart tears down everything else built for that identity, and a +background node is Electrum, the LSP peer and the swap-in watcher for a profile +that is not open. If a wallet feature ships and a node has something to do while +its profile is closed, this is the decision to revisit, and it is one branch in +`switchToIdentity`. + +### Stamping the owner at every call site + +Eighteen view models each passing `activeUserPublicKey` into a queue call. +Mechanical, wide, and forgettable: the nineteenth would pass nothing. The +repository is built once, has the identity flow one parameter away, and every +request it writes is made as the active identity by construction. + +### Filtering the broadcast queue by owner + +Symmetry with the fetch queues. Rejected because a signed event needs no key to +send and holding A's message until A returns is a delivery failure the user would +never be told about. The asymmetry is the point: fetches are opened with a key, +sends are not. + +### A database per identity + +Isolates everything at once, including the group-membership collision. Costs the +shared profile cache — every kind 0 fetched once per profile — a Room instance +rebuilt on every switch, and a migration that splits the existing file by +`userPublicKey` across every table that has one and guesses for the tables that +do not. Deferred rather than rejected: it is the answer if the collision becomes +common, and nothing in this plan makes it harder to do later. From d957ee8de59d2c6f969fd475eec52bab9c1c0dbd Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 00:20:22 +0200 Subject: [PATCH 43/71] feat(identity): a seed's key is a credential, and the seed is the wallet attached to it Phase 1 of docs/multiple-profiles.md. No library change: the file format, the encrypted writer and the manager all exist, and the app already wrote the credentials file from the seed writer -- to delete a public entry a seed superseded. This changes what it writes there, and what the listing believes. Until now a seed's nostr key was never in nostr-credentials.dat. It was derived from the words at listing, to know which npub to show, and from the running node at activation, to know which key to sign with -- so a seed-backed profile existed only as a derivation, and the app had to start a Lightning node to find out who it was. Both sign-in plans made "one key, one file" an invariant, and it was the wrong one: it said a wallet is a profile. Now the credentials file is the list of profiles and a seed is a wallet attached to the entry its key derives. writeMnemonic writes two things, credential first: a Secret for the derived key under its x-only pubkey, replacing a public entry where there is one, and then the seed. Credential first because a crash between the two leaves a bare-key profile the phrase completes, which is a valid thing to hold and says the model out loud -- the profile exists, then a wallet is attached to it. The one refusal it drops is the phrase of a key held as a bare secret, SeedAlreadyExists under the nsec plan: the device did not have that wallet, so this is the profile acquiring the wallet that derives it. The entry stays a secret for the same key, the id becomes the wallet's, and the bare key's preference files go with the old id -- the profile's preferences are the wallet's now, fresh, which is right since there is a new secret to back up. The same seed twice is still refused, by wallet id, and is the only way a profile with a wallet attached is offered its phrase again. SeedCredentials.reconcile is the repair for every seed already on a device, beside migrateFromNostrKeys in listIdentities and shaped like it: a named, idempotent write, one file write for however many seeds are missing, nothing at all on a device with none or one already repaired. A failed write is a result, not a throw, and carries the map that was read: the listing goes on with it and merge derives the key of a seed that has no credential, so a seed this could not repair is still listed. A failed write must never hide a wallet. The plan had the repair running before the credentials file was read; it runs after, and hands the listing what it returns, so the file is decrypted once -- the doc now says so. StoredIdentity.merge inverts: the credentials are the list, and each seed is attached to the Secret its key derives -- listed once, as Mnemonic under the wallet's id, carrying the credential's key -- where before the seeds were the list and a secret for a seed's key was listed twice under two ids. Mnemonic gains privateKey. A Public for a seed's key is skipped with a log line, since the next repair upgrades it; a seed with no credential is listed by derivation, with a log line. IdentityKind.Mnemonic's doc changes to what the kind now means: the name records the attachment, not the source. setActiveWallet takes the StoredIdentity.Mnemonic and builds the identity from the credential's key, with the node's derivation as a cross-check -- a check(), because a node disagreeing with the credentials file is the one corruption worth refusing to run under, and it cannot fail for a file the repair wrote. The node still starts for a profile with a wallet attached: not for the key any more, but for what startNewBusiness does besides -- metadata, preferences, last-used build, and on Android the channel watcher a restored Phoenix phrase may need. Making it lazy is now one branch and is named as its own decision. forgetNostrCredential refuses a second thing: a key a seed derives. The seed would derive it again and the next repair would write it back, so a forget that succeeded would undo itself. NotACredential becomes WalletAttached in the writer's result and ForgetIdentity's outcome, since that is now the only reason a signing profile cannot be forgotten -- a key not in the file at all can, since the repair, only be a seed's key the repair could not write. The two sign-in docs' tables each gain a row pointing here for the invariant this supersedes. Tests: SeedCredentialsJvmTest against a device from before -- seed.dat written directly, no credentials -- writes exactly what is missing in one write; a device already repaired is not written to again, checked by the file's bytes since a rewrite would carry a fresh iv; a public entry for a seed's key is upgraded; bare keys are untouched; and a write that fails, with the key store locked, is reported with the map that was read and the wallet is still listed. IdentityWriterJvmTest drives the callback-shaped seed writer through a CompletableDeferred on a real Main dispatcher, since it reports after a real one-second delay: a phrase writes both files, its nsec and npub are then duplicates and its forget is WalletAttached; the same phrase twice is refused; the phrase of a bare key attaches, under the wallet's id, with the bare key's preferences gone; the phrase of a key held read-only attaches and the entry becomes a secret. StoredIdentityJvmTest lists a secret-plus-seed once with the credential's key, a seed without a credential by derivation, and a public entry for a seed's key as the wallet only. Not under test: the activation's cross-check, because a PhoenixBusiness cannot be built without a node. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@3008137e3f5241f1ebd69db1a13f5522e864a55d --- .../mantra/compose/identity/ForgetIdentity.kt | 12 +- .../press/mantra/compose/identity/Identity.kt | 11 +- .../mantra/compose/identity/IdentityWriter.kt | 89 +++++--- .../compose/identity/SeedCredentials.kt | 77 +++++++ .../mantra/compose/identity/StoredIdentity.kt | 85 +++++--- .../SovereignWalletStartupScreen.kt | 6 +- .../ui/view/model/NostrSecretViewModel.kt | 2 +- .../compose/ui/view/model/SignOutViewModel.kt | 2 +- .../ui/view/model/SovereignWalletViewModel.kt | 51 ++++- .../compose/identity/ForgetIdentityJvmTest.kt | 6 +- .../compose/identity/IdentityWriterJvmTest.kt | 159 ++++++++++++++- .../identity/SeedCredentialsJvmTest.kt | 192 ++++++++++++++++++ .../compose/identity/StoredIdentityJvmTest.kt | 51 +++-- docs/multiple-profiles.md | 15 +- docs/npub-sign-in.md | 1 + docs/nsec-sign-in.md | 1 + 16 files changed, 649 insertions(+), 111 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/SeedCredentials.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/SeedCredentialsJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt index d37c2c03..887b2572 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/ForgetIdentity.kt @@ -11,9 +11,9 @@ import press.mantra.compose.repository.NostrRepository * Lifted out of `NostrSecretViewModel.forgetKey`, where it was written for an nsec, so * that the two exits a read-only identity has -- sign out, and *use a different key* on * the not-found screen -- run the same sequence rather than a copy of it. The first step - * is the same call for both credential kinds since the credentials file; only a mnemonic - * identity is refused, because removing a seed is a wallet question this does not - * answer. + * is the same call for both credential kinds since the credentials file; only a profile + * with a wallet attached is refused, because removing a seed is a wallet question this + * does not answer, and because the seed would derive the key again. * * The two writers are injected as functions so the sequence can be pinned in a test * without a key store, as the nsec view model's already is. @@ -24,8 +24,8 @@ object ForgetIdentity { /** The device no longer holds anything for the identity. */ data object Forgotten : Outcome - /** A mnemonic identity: its key is in the seed, and this does not remove seeds. */ - data object NotACredential : Outcome + /** A profile with a wallet attached: the seed derives its key, and this does not remove seeds. */ + data object WalletAttached : Outcome /** The credentials file could not be read; nothing was changed. */ data object CannotForget : Outcome @@ -44,7 +44,7 @@ object ForgetIdentity { nostrRepository.forgetLocalAccount(identity.nostrPublicKey) Outcome.Forgotten } - is IdentityWriter.ForgetNostrCredentialResult.NotACredential -> Outcome.NotACredential + is IdentityWriter.ForgetNostrCredentialResult.WalletAttached -> Outcome.WalletAttached is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> Outcome.CannotForget } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt index 7ccd555a..5741fb89 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -14,16 +14,21 @@ import fr.acinq.phoenix.utils.preferences.InternalPrefs import fr.acinq.phoenix.utils.preferences.UserPrefs /** - * Where an identity's key came from, which decides what else it can have. + * What the device holds beside an identity's key, which decides what else it can have. * * The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at * `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the * key and a wallet; a bare key yields nothing further, so an identity made from * one can never grow a node behind it; and a bare public key yields nothing at all, - * not even a signature. See docs/nsec-sign-in.md and docs/npub-sign-in.md. + * not even a signature. See docs/nsec-sign-in.md, docs/npub-sign-in.md and + * docs/multiple-profiles.md. */ enum class IdentityKind { - /** Twelve words. The nostr key is derived from them, and so is a wallet. */ + /** + * A key the device also holds a seed for. The key is a credential like any other + * -- it is read from the credentials file, not derived from the node -- and the seed + * is the wallet attached to it. The name records the attachment, not the source. + */ Mnemonic, /** A bare nostr secret. Nothing else can be derived from it. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt index 5fcea706..c784828c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -43,10 +43,15 @@ import press.mantra.compose.ui.view.model.WritingSeedState * key, the other hash160 of the nostr key -- and the database is keyed by pubkey, so two * identities for one pubkey would share every row and disagree about which is active. * - * With one exception, which is the point of the credentials file: a public key already - * here read-only is not a duplicate of the secret that signs as it. The device does not + * With two exceptions, which are the point of the credentials file. A public key already + * here read-only is not a duplicate of the secret that signs as it: the device does not * have that secret, and refusing it would be false. [writeNostrKey] replaces the entry - * in one write; [writeMnemonic] has to span two files and says which goes first. + * in one write. And a bare secret already here is not a duplicate of the recovery phrase + * that derives it: the device does not have that wallet, and pasting the phrase is the + * profile acquiring the wallet that derives it. [writeMnemonic] writes the credential + * first and then the seed -- see docs/multiple-profiles.md, Phase 1 -- and every seed's + * key is a `Secret` entry in the credentials file, so that the file is the list of + * profiles and the seed is a wallet attached to one of them. */ object IdentityWriter { @@ -130,34 +135,43 @@ object IdentityWriter { return@launch } existingSeeds.containsKey(newWalletId) -> { + // The same seed, and so the same nostr key with a wallet already attached. + // Two different seeds cannot derive one nostr key, so this is the only + // way a profile with a wallet attached is offered its phrase again. log.i("attempting to import a seed that already exists, aborting...") onWritingSeedError.invoke( WritingSeedState.Error.SeedAlreadyExists ) return@launch } - existingCredentials[newNostrPublicKey] is NostrCredential.Secret -> { - // The same npub is already here as a bare key. The id check above cannot - // see that -- different hash, different key -- so it is asked by pubkey. - log.i("attempting to import a seed whose nostr key is already here, aborting...") - onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists - ) - return@launch - } else -> { - if (existingCredentials[newNostrPublicKey] is NostrCredential.Public) { - // The npub is here read-only, and the words that sign as it have just - // been pasted: an upgrade, across two files. The credential goes - // first, then the seed. A crash between the two loses the read-only - // identity, which the npub pasted again restores; the reverse order - // would leave one npub listed twice under two ids. - log.i("the seed's nostr key is here read-only; replacing it with the wallet") - NostrCredentialManager.writeToDisk( - phoenixGlobal, - EncryptedNostrCredentials.encrypt(existingCredentials - newNostrPublicKey), - ) + // The credential first, then the seed. What the credentials file holds + // for this key beforehand decides what the write means, not whether it + // happens: nothing, and this is a new profile with a wallet; a public + // entry, and it is the read-only upgrade landing on a wallet; a secret, + // and it is a bare key acquiring the wallet that derives it, which used + // to be refused as a duplicate and is not one -- the device did not have + // the wallet. In every case the entry becomes the derived key. + // + // A crash between the two writes leaves a bare-key profile whose key + // the phrase derives: a valid thing to hold, and pasting the phrase + // again completes it. The reverse order would leave a seed with no + // credential, which the listing derives around and the repair fixes -- + // also recoverable, but this order says the model out loud: the profile + // exists, and then a wallet is attached to it. + val existingEntry = existingCredentials[newNostrPublicKey] + val bareKeyId = if (existingEntry != null) StoredIdentity.nostrPublic(newNostrPublicKey).id else null + when (existingEntry) { + null -> Unit + is NostrCredential.Public -> log.i("the seed's nostr key is here read-only; it becomes a profile with a wallet") + is NostrCredential.Secret -> log.i("the seed's nostr key is here as a bare key; attaching the wallet") } + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt( + existingCredentials + (newNostrPublicKey to NostrCredential.Secret(keyManager.nostrPrivateKey())) + ), + ) val newSeedMap = existingSeeds + (newWalletId to mnemonics) val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) @@ -166,6 +180,13 @@ object IdentityWriter { } else { log.i("successfully created wallet=$newWalletId") } + if (bareKeyId != null) { + // The profile's id is its wallet's now, so its preferences are the + // wallet's -- fresh, as a new wallet's are, which is right: there is + // a new secret to back up. The files under the bare key's id would + // otherwise sit on the disk with nothing reading them. + DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(bareKeyId) + } } } @@ -281,8 +302,14 @@ object IdentityWriter { sealed class ForgetNostrCredentialResult { data object Forgotten : ForgetNostrCredentialResult() - /** The key is not in `nostr-credentials.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */ - data object NotACredential : ForgetNostrCredentialResult() + /** + * A seed derives the key: the profile has a wallet attached, and removing a seed is + * a wallet question this does not answer. Also the answer for a key that is not in + * `nostr-credentials.dat` at all, which since the repair can only be a seed's key + * the repair could not write -- and either way, taking the entry would not take the + * key: the seed would derive it again and the next listing would put it back. + */ + data object WalletAttached : ForgetNostrCredentialResult() data object CannotLoadKeys : ForgetNostrCredentialResult() } @@ -291,7 +318,8 @@ object IdentityWriter { * The inverse of [writeNostrKey] and [writeNostrPublicKey] alike: removes the entry, * whichever kind it is, from `nostr-credentials.dat` and deletes the identity's * preference files. The profile stays on the relays, and the same key can be signed - * in again. Only for a credential -- see [ForgetNostrCredentialResult.NotACredential]. + * in again. Only for a profile with no wallet attached -- see + * [ForgetNostrCredentialResult.WalletAttached]. */ suspend fun forgetNostrCredential( log: Logger, @@ -299,14 +327,19 @@ object IdentityWriter { id: WalletId, nostrPublicKey: HexKey, ): ForgetNostrCredentialResult { + val seeds = seedPublicKeys(phoenixGlobal) val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) - if (existing == null) { + if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") return ForgetNostrCredentialResult.CannotLoadKeys } + if (nostrPublicKey in seeds) { + log.i("asked to forget a key a seed derives; the wallet has to go first") + return ForgetNostrCredentialResult.WalletAttached + } if (!existing.containsKey(nostrPublicKey)) { log.i("asked to forget a key that is not a credential on this device") - return ForgetNostrCredentialResult.NotACredential + return ForgetNostrCredentialResult.WalletAttached } NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(existing - nostrPublicKey)) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/SeedCredentials.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/SeedCredentials.kt new file mode 100644 index 00000000..11f1af26 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/SeedCredentials.kt @@ -0,0 +1,77 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.EncryptedNostrCredentials +import fr.acinq.phoenix.security.NostrCredential + +/** + * The credential a seed always implied. + * + * Until docs/multiple-profiles.md, a seed's nostr key was never written to the + * credentials file: it was derived from the words at listing and from the running node + * at activation, and the writers kept one key out of two files. That put the wallet + * where the profile should be. Now the credentials file is the list of profiles and a + * seed is a wallet attached to one, so every seed's key has to be in it -- written by + * `IdentityWriter.writeMnemonic` for a seed created or restored from here on, and by + * this for every seed that was already on the device. + * + * A named, idempotent write that runs from `SovereignWalletViewModel.listIdentities`, + * beside `NostrCredentialManager.migrateFromNostrKeys` and for the same reason: a + * listing that writes is a surprise, so the write is a step with a name, a result and + * a test, rather than a side effect of reading. On a device with no seeds, or one + * already repaired, it writes nothing. + */ +object SeedCredentials { + + sealed interface Result { + /** The credentials the listing should use: repaired where the write succeeded, as read where it did not. */ + val credentials: Map + + /** [count] seeds had no secret credential, and now do. One write. */ + data class Written(val count: Int, override val credentials: Map) : Result + + /** Every seed's key was already a secret credential. Nothing was written. */ + data class NotNeeded(override val credentials: Map) : Result + + /** + * The write failed; the file is as it was and [credentials] is what was read. The + * listing goes on: `StoredIdentity.merge` derives the key of a seed that has no + * credential, so a seed this could not repair is still listed. A failed write must + * never hide a wallet, which is why this is a result and not a throw. + */ + data class Failed(val cause: Throwable, override val credentials: Map) : Result + } + + /** + * Writes a `Secret` for every seed in [wallets] whose derived key is not one in + * [credentials], replacing a `Public` entry for that key where there is one -- the + * read-only upgrade landing on the wallet that was here all along. + */ + fun reconcile( + log: Logger, + phoenixGlobal: PhoenixGlobal, + wallets: Map, + credentials: Map, + ): Result { + val missing = wallets.values + .map { StoredIdentity.nostrPrivateKeyOf(it.words) } + .filter { credentials[it.nostrPublicKeyHex()] !is NostrCredential.Secret } + if (missing.isEmpty()) return Result.NotNeeded(credentials) + + val repaired = credentials + missing.associate { it.nostrPublicKeyHex() to NostrCredential.Secret(it) } + return try { + NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(repaired)) + log.i { "wrote the credential for ${missing.size} seed(s) that had none" } + Result.Written(missing.size, repaired) + } catch (e: Exception) { + log.e("could not write the credentials of ${missing.size} seed(s); listing them by derivation", e) + Result.Failed(e, credentials) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt index ec309a7d..9390f081 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt @@ -16,31 +16,43 @@ import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.security.NostrCredential /** - * An identity this device holds something for, as listed before any of them is started. + * A profile this device holds something for, as listed before any of them is started. * - * Two stores feed this -- `seed.dat` for wallets, `nostr-credentials.dat` for bare keys - * and bare public keys -- and the startup screen wants one list. The nostr public key - * is on every kind because it is the one thing they have in common and the one thing a - * duplicate check has to compare: a wallet and an imported key can be the *same* npub - * under different ids. + * The credentials file is the list of profiles: a `Secret` entry for every key the + * device can sign as, a `Public` one for every key it can only look at. `seed.dat` is + * the list of wallets, and a wallet is *attached* to the profile whose key it derives + * -- matched by public key, since a seed's key is written to the credentials file when + * the seed is (`IdentityWriter.writeMnemonic`) and repaired into it for every seed + * already here (`SeedCredentials.reconcile`). See docs/multiple-profiles.md, Phase 1. + * The nostr public key is on every kind because it is the one thing they have in + * common and the one thing that attaches a wallet to a profile or refuses a duplicate. * * On what this holds: `SovereignWalletViewModel.availableWallets` has always carried * decrypted words for the view model's life, because starting the node needs them. - * [NostrSecret] carries its key for the same reason and is no worse; decrypting at - * activation instead would be an improvement for both kinds and belongs to both. + * [NostrSecret] and [Mnemonic] carry their key for the same reason and are no worse; + * decrypting at activation instead would be an improvement for every kind and belongs + * to all of them. */ sealed interface StoredIdentity { val id: WalletId val kind: IdentityKind val nostrPublicKey: HexKey + /** + * A key the device also holds a seed for. The key is the credential's, so that + * activating this profile does not depend on the node to learn who it is; the seed + * is the wallet attached to it, and its id is the wallet's -- `hash160(nodeId)`, as + * it has always been -- because the library keys the node's preferences and + * metadata by that id and nothing has to move. + */ data class Mnemonic( val userWallet: UserWallet, - override val nostrPublicKey: HexKey, + val privateKey: PrivateKey, ) : StoredIdentity { override val id: WalletId get() = userWallet.walletId override val kind: IdentityKind get() = IdentityKind.Mnemonic - override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey)" + override val nostrPublicKey: HexKey = privateKey.nostrPublicKeyHex() + override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey, key=)" } data class NostrSecret( @@ -65,18 +77,23 @@ sealed interface StoredIdentity { companion object { /** - * The nostr public key a wallet's words derive. `SeedManager.loadAndDecrypt` has - * already built exactly this key manager to learn the node id, so this is the second - * derivation of it, not a new cost. + * The nostr key a wallet's words derive, at the NIP-06 path. `SeedManager.loadAndDecrypt` + * has already built exactly this key manager to learn the node id, so this is the + * second derivation of it, not a new cost. It is what attaches a seed to its + * credential, and what the credential is repaired from when a seed has none. */ - fun nostrPublicKeyOf(words: List): HexKey = LocalKeyManager( + fun nostrPrivateKeyOf(words: List): PrivateKey = LocalKeyManager( seed = MnemonicCode.toSeed(words, "").byteVector(), chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, - ).nostrPrivateKey().nostrPublicKeyHex() + ).nostrPrivateKey() + /** The x-only public key of [nostrPrivateKeyOf]. */ + fun nostrPublicKeyOf(words: List): HexKey = nostrPrivateKeyOf(words).nostrPublicKeyHex() + + /** A wallet whose credential is missing: the key is derived from the words, as it always was. */ fun mnemonic(userWallet: UserWallet): Mnemonic = - Mnemonic(userWallet, nostrPublicKeyOf(userWallet.words)) + Mnemonic(userWallet, nostrPrivateKeyOf(userWallet.words)) fun nostrSecret(privateKey: PrivateKey): NostrSecret { val xOnly = privateKey.publicKey().xOnly() @@ -93,36 +110,38 @@ sealed interface StoredIdentity { ) /** - * One map from the two stores. [credentials] is keyed by x-only public key hex, as + * One map from the two stores: the credentials are the list, and the seeds are + * attached to it. [credentials] is keyed by x-only public key hex, as * `nostr-credentials.dat` is; a secret's id is derived from its own key, so a * stored secret whose map key disagrees with its public key is dropped here too, * though `EncryptedNostrCredentials` refuses such a file before it gets this far. * - * One entry per public key is the credentials file's own invariant, so there is no - * precedence between credentials to decide. There is one between a seed and a - * credential, for the one upgrade that has to span both files -- a recovery phrase - * pasted over a public credential, which `IdentityWriter.writeMnemonic` performs as - * two writes: a `Public` whose public key a seed derives is dropped, with a log line, - * and the next write repairs the file. A *secret* for a seed's key is kept, as it - * always was: two ids for one npub is a duplicate the writers refuse, not a state - * the listing hides. + * A `Secret` whose key a seed derives is that seed's profile, listed once, as a + * [Mnemonic] under the wallet's id and carrying the credential's key. A `Public` + * for such a key is skipped with a log line: `IdentityWriter.writeMnemonic` replaces + * it with a secret before it writes the seed, and `SeedCredentials.reconcile` does + * the same for one left behind, so it is a state the next listing no longer sees. + * + * A seed with no credential at all -- the repair failed to write, or a crash fell + * between the seed writer's two files -- is listed as it always was, with its key + * derived from the words. A failed write must never hide a wallet. */ fun merge( wallets: Map, credentials: Map, ): Map { + val walletsByPublicKey = wallets.values.associateBy { nostrPublicKeyOf(it.words) } val merged = LinkedHashMap() - wallets.values.forEach { userWallet -> merged[userWallet.walletId] = mnemonic(userWallet) } - val seedPublicKeys = merged.values.map { it.nostrPublicKey }.toSet() credentials.forEach { (publicKeyHex, credential) -> when (credential) { is NostrCredential.Secret -> { - val stored = nostrSecret(credential.privateKey) + val wallet = walletsByPublicKey[publicKeyHex] + val stored = if (wallet != null) Mnemonic(wallet, credential.privateKey) else nostrSecret(credential.privateKey) if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored } is NostrCredential.Public -> { - if (publicKeyHex in seedPublicKeys) { - log.w { "public credential for a key a seed already derives; listing the wallet only" } + if (publicKeyHex in walletsByPublicKey) { + log.w { "public credential for a key a seed derives; the next repair upgrades it" } } else { val stored = runCatching { nostrPublic(publicKeyHex) }.getOrNull() if (stored != null) merged[stored.id] = stored @@ -130,6 +149,12 @@ sealed interface StoredIdentity { } } } + walletsByPublicKey.values.forEach { wallet -> + if (wallet.walletId !in merged) { + log.w { "wallet=${wallet.walletId} has no credential for its nostr key; deriving it" } + merged[wallet.walletId] = mnemonic(wallet) + } + } return merged } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 32297e2b..39c7db66 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -177,8 +177,12 @@ fun SovereignWalletStartupScreen( doLoadWallet = { identity -> when (identity) { is StoredIdentity.Mnemonic -> { + // The node starts for the wallet attached to this profile -- + // its preferences, its metadata, the channel watcher -- and no + // longer for the key, which the identity reads from the + // credential the node is handed to cross-check. sovereignWalletStartupViewModel.startupNode(walletId = identity.id, words = identity.userWallet.words, onStartupSuccess = { - sovereignWalletViewModel.setActiveWallet(walletId = identity.id, business = it) + sovereignWalletViewModel.setActiveWallet(stored = identity, business = it) onSuccessfulStartup.invoke() }) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt index 88bce8c0..f56a0962 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt @@ -186,7 +186,7 @@ class NostrSecretViewModel( _forgetting.value = NostrSecretUIState.Forgetting.Idle withContext(Dispatchers.Main) { onForgotten() } } - is ForgetIdentity.Outcome.NotACredential, + is ForgetIdentity.Outcome.WalletAttached, is ForgetIdentity.Outcome.CannotForget -> { _forgetting.value = NostrSecretUIState.Forgetting.Failed } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt index 29773e92..f9657b88 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignOutViewModel.kt @@ -76,7 +76,7 @@ class SignOutViewModel( _state.value = State.Idle withContext(Dispatchers.Main) { onSignedOut() } } - is ForgetIdentity.Outcome.NotACredential, + is ForgetIdentity.Outcome.WalletAttached, is ForgetIdentity.Outcome.CannotForget -> { _state.value = State.Failed } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index d000c5c2..1ab201d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.launch import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.SeedCredentials import press.mantra.compose.identity.StoredIdentity sealed class WritingSeedState { @@ -87,8 +88,9 @@ class SovereignWalletViewModel( val listWalletState = _listWalletState.asStateFlow() /** - * Every identity this device holds a secret for, from both stores -- `seed.dat` and - * `nostr-keys.dat` -- keyed by the id the preferences and metadata use. + * Every profile this device holds something for -- the credentials file, with each + * seed attached to the profile its key derives -- keyed by the id the preferences + * and metadata use. */ private val _availableIdentities = MutableStateFlow>(emptyMap()) val availableIdentities = _availableIdentities.asStateFlow() @@ -145,10 +147,17 @@ class SovereignWalletViewModel( } /** - * Activates the identity a just-started node derives: the mnemonic kind, with the - * node behind it. + * Activates a profile with a wallet attached, once its node has started. + * + * The key is the credential's, not the node's. The node still derives it -- and is + * asked to, as a cross-check -- but the credentials file is what the app signs with + * now, and a node that disagrees with it is the one corruption worth refusing to run + * under: the throw lands in the startup view model's handler and shows as a startup + * error. It cannot happen for a file the repair wrote, since that derived the key + * from the same seed. */ - fun setActiveWallet(walletId: WalletId, business: PhoenixBusiness) { + fun setActiveWallet(stored: StoredIdentity.Mnemonic, business: PhoenixBusiness) { + val walletId = stored.id val keyManager = business.walletManager.keyManager.value if (keyManager == null) { // `startNewBusiness` loads the wallet before it reports success, so a business @@ -157,13 +166,16 @@ class SovereignWalletViewModel( log.e { "business for wallet=$walletId started without a key manager" } return } + check(keyManager.nostrPrivateKey() == stored.privateKey) { + "the node for wallet=$walletId derives a different nostr key than its credential holds" + } val dataStoreManager = DataStoreManager(business) setActiveIdentity( Identity.signing( id = walletId, kind = IdentityKind.Mnemonic, - nostrPrivateKey = keyManager.nostrPrivateKey(), + nostrPrivateKey = stored.privateKey, userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId), internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId), business = business, @@ -173,12 +185,14 @@ class SovereignWalletViewModel( } /** - * Reads both stores and publishes the merged list, registering metadata for any id - * seen for the first time. + * Reads both stores, repairs the credentials file so that every seed's key is in it, + * and publishes the merged list, registering metadata for any id seen for the first + * time. * * A failure in either file is surfaced, not skipped: a corrupt `nostr-keys.dat` would * otherwise drop every imported identity from the list without a word, and the seed - * file has always been handled this way. + * file has always been handled this way. A failure of the *repair* is the one thing + * logged and not surfaced -- the listing derives around it, see [SeedCredentials]. */ fun listIdentities(onDone: () -> Unit) { viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> @@ -237,7 +251,7 @@ class SovereignWalletViewModel( } } - val credentials: Map = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) { + val readCredentials: Map = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) { is DecryptNostrCredentialsResult.Failure.SerializationError -> { log.e { "cannot deserialize nostr credentials file" } _listWalletState.value = ListWalletState.Error.Serialization @@ -262,6 +276,23 @@ class SovereignWalletViewModel( is DecryptNostrCredentialsResult.Success -> result.credentials } + // The second write a listing makes, and for the same reason as the first: a + // seed's key belongs in the credentials file, and every seed from before that + // was so gets its entry here, once. The listing uses what the repair returns -- + // the repaired map, or the one read if the write failed -- so a seed is listed + // either way. + val credentials = when (val repair = SeedCredentials.reconcile(log, phoenixGlobal, wallets, readCredentials)) { + is SeedCredentials.Result.Written -> { + log.i { "wrote the credential of ${repair.count} seed(s) into the credentials file" } + repair.credentials + } + is SeedCredentials.Result.NotNeeded -> repair.credentials + is SeedCredentials.Result.Failed -> { + log.e("the credentials of ${wallets.size} seed(s) could not be repaired; listing by derivation", repair.cause) + repair.credentials + } + } + val identities = StoredIdentity.merge(wallets, credentials) val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt index e746653e..53651918 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/ForgetIdentityJvmTest.kt @@ -74,12 +74,12 @@ class ForgetIdentityJvmTest { } @Test - fun `a mnemonic identity is refused before anything else is touched`() = runBlocking { + fun `a profile with a wallet attached is refused before anything else is touched`() = runBlocking { val recorder = Recorder() - val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.NotACredential) + val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.WalletAttached) - assertEquals(ForgetIdentity.Outcome.NotACredential, outcome) + assertEquals(ForgetIdentity.Outcome.WalletAttached, outcome) assertEquals(listOf("forgetNostrCredential"), recorder.effects) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt index 28e2cb61..e19b2f54 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt @@ -2,19 +2,35 @@ package press.mantra.compose.identity import androidx.datastore.preferences.core.PreferenceDataStoreFactory import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.MnemonicCode import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.byteVector import fr.acinq.lightning.Lightning +import fr.acinq.lightning.crypto.LocalKeyManager import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager import fr.acinq.phoenix.managers.computePreferencePath import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.security.JvmKeyStore import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.MnemonicLanguage import fr.acinq.phoenix.utils.PlatformContext import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout import okio.FileSystem import okio.SYSTEM +import press.mantra.compose.ui.view.model.WritingSeedState import java.io.File import java.nio.file.Files import kotlin.test.AfterTest @@ -23,18 +39,21 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertTrue /** - * The writers for a bare key and a bare public key, against a real key store and a - * real directory. + * The three writers, against a real key store and a real directory. * - * What is pinned is the duplicate rule, its one exception, and the inverse. A second + * What is pinned is the duplicate rule, its two exceptions, and the inverse. A second * import of the same key is refused by public key, the same npub imported twice being * the one thing the id check cannot see; a public key already here is *not* a duplicate * of the secret that signs as it, and the entry becomes a secret one under the id it - * had; and forgetting takes the entry, whichever kind, out of the file and the identity's - * preference files off the disk, leaving every other entry where it was. + * had; a bare secret already here is *not* a duplicate of the phrase that derives it, + * and the seed attaches to it; a phrase writes its key to the credentials file as well + * as its words to the seed file; and forgetting takes the entry, whichever kind, out of + * the file and the identity's preference files off the disk, leaving every other entry + * where it was -- unless a seed derives the key, in which case nothing is touched. */ class IdentityWriterJvmTest { @@ -51,8 +70,14 @@ class IdentityWriterJvmTest { private val first = PrivateKey(Lightning.randomBytes(32)) private val second = PrivateKey(Lightning.randomBytes(32)) + private val scope = CoroutineScope(Job()) + @BeforeTest fun setUp() { + // The seed writer reports on Main, as the create screen's state machine needs it + // to; a real dispatcher rather than a test one, because it reports after a real + // one-second delay and a virtual clock would never reach it. + Dispatchers.setMain(Dispatchers.Default) storeDir = Files.createTempDirectory("mantra-identity-writer-store").toFile() appDir = Files.createTempDirectory("mantra-identity-writer-app").toFile() JvmKeyStore.lock() @@ -67,11 +92,48 @@ class IdentityWriterJvmTest { @AfterTest fun tearDown() { + scope.cancel() + Dispatchers.resetMain() JvmKeyStore.lock() storeDir.deleteRecursively() appDir.deleteRecursively() } + /** Twelve fresh words, and what they derive. */ + private class Phrase { + val words: List = MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist()) + private val keyManager = LocalKeyManager( + seed = MnemonicCode.toSeed(words, "").byteVector(), + chain = NodeParamsManager.chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ) + val walletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + val nostrKey = StoredIdentity.nostrPrivateKeyOf(words) + val nostrPublicKey = nostrKey.nostrPublicKeyHex() + } + + /** The callback-shaped seed writer, awaited: exactly one of its two callbacks fires. */ + private suspend fun writePhrase(phrase: Phrase): Result { + val outcome = CompletableDeferred>() + IdentityWriter.writeMnemonic( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = globalPrefs, + writingState = WritingSeedState.Init, + viewModelScope = scope, + mnemonics = phrase.words, + onWritingSeedError = { outcome.complete(Result.failure(IllegalStateException(it.toString()))) }, + onWritingSeedStateWriting = {}, + isRestoringWallet = false, + isTorEnabled = false, + customElectrumServer = null, + onSeedWritten = { outcome.complete(Result.success(it)) }, + ) + return withTimeout(15_000) { outcome.await() } + } + + private fun seeds() = SeedManager.loadAndDecryptOrNull(phoenixGlobal) + private suspend fun write(key: PrivateKey) = IdentityWriter.writeNostrKey( log = log, phoenixGlobal = phoenixGlobal, @@ -173,15 +235,98 @@ class IdentityWriterJvmTest { assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(readOnly))) } - /** A wallet's nostr key is in the seed, not here; removing a seed is a wallet question. */ + /** + * Not in the file at all. Since the repair that can only be a seed's key the repair + * could not write, and the answer is the same as for one it did: a wallet is attached. + */ @Test fun `a key that is not in the file is not forgotten`() = runBlocking { write(first) val other = StoredIdentity.nostrSecret(second) - assertIs( + assertIs( IdentityWriter.forgetNostrCredential(log, phoenixGlobal, other.id, other.nostrPublicKey) ) assertEquals(1, credentials()?.size) } + + /** + * The credential a seed always implied. A phrase writes two files: its key as a secret + * in the credentials file, and its words in the seed file. The profile's id is the + * wallet's, and the nsec of that key is then a duplicate -- the device holds more than + * the key for it. + */ + @Test + fun `a phrase writes its key to the credentials file as well as its words to the seed file`() = runBlocking { + val phrase = Phrase() + + val id = writePhrase(phrase).getOrThrow() + + assertEquals(phrase.walletId, id) + assertEquals(mapOf(phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey)), credentials()) + assertEquals(setOf(phrase.walletId), seeds()?.keys) + assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb"))) + + assertIs(write(phrase.nostrKey)) + assertIs(writePublic(phrase.nostrKey)) + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, id, phrase.nostrPublicKey) + ) + assertEquals(1, credentials()?.size, "the forget changed nothing") + } + + /** The same seed twice is the one thing the phrase writer still refuses. */ + @Test + fun `the same phrase twice is refused`() = runBlocking { + val phrase = Phrase() + writePhrase(phrase).getOrThrow() + + val second = writePhrase(phrase) + + assertTrue(second.isFailure) + assertTrue(second.exceptionOrNull()?.message?.contains("SeedAlreadyExists") == true, "$second") + assertEquals(1, seeds()?.size) + } + + /** + * The second exception to the duplicate rule, and the first attachment: the phrase of + * a key held as a bare secret used to be refused, and is the profile acquiring the + * wallet that derives it. The entry stays a secret for the same key; the id becomes + * the wallet's, and the bare key's preference files go with the old id. + */ + @Test + fun `the phrase of a key held as a bare secret attaches its wallet`() = runBlocking { + val phrase = Phrase() + val bare = assertIs(write(phrase.nostrKey)) + val bareUserPrefs = userPrefsFile(StoredIdentity.nostrSecret(phrase.nostrKey)) + assertTrue(FileSystem.SYSTEM.exists(bareUserPrefs)) + + val id = writePhrase(phrase).getOrThrow() + + assertEquals(phrase.walletId, id) + assertNotEquals(bare.id, id, "the profile's id is its wallet's now") + assertEquals(mapOf(phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey)), credentials()) + assertEquals(setOf(phrase.walletId), seeds()?.keys) + assertFalse(FileSystem.SYSTEM.exists(bareUserPrefs), "the bare key's preferences went with its id") + assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb"))) + } + + /** The read-only upgrade, landing on a wallet: the public entry becomes a secret, and the seed attaches. */ + @Test + fun `the phrase of a key held read-only attaches its wallet and the entry becomes a secret`() = runBlocking { + val phrase = Phrase() + writePublic(phrase.nostrKey) + writePublic(second) + + val id = writePhrase(phrase).getOrThrow() + + assertEquals(phrase.walletId, id) + assertEquals( + mapOf( + phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey), + second.nostrPublicKeyHex() to NostrCredential.Public, + ), + credentials(), + ) + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/SeedCredentialsJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/SeedCredentialsJvmTest.kt new file mode 100644 index 00000000..2cff0a73 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/SeedCredentialsJvmTest.kt @@ -0,0 +1,192 @@ +package press.mantra.compose.identity + +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.byteVector +import fr.acinq.lightning.Lightning +import fr.acinq.lightning.crypto.LocalKeyManager +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.SeedManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.EncryptedNostrCredentials +import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.phoenix.utils.PlatformContext +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The repair that runs at listing, against a device from before the credentials file + * held a seed's key: `seed.dat` written directly, as every wallet on every device was + * until docs/multiple-profiles.md, and no credential for any of them. + * + * What is pinned is that the repair writes exactly what is missing, once; that a + * device already repaired is not written to again -- the file's bytes are compared, + * since a rewrite would carry a fresh iv and could not be told apart by its contents; + * that a public entry for a seed's key is upgraded rather than left beside a secret; + * and that a write that fails is reported with the map that was read, not thrown. + */ +class SeedCredentialsJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val log = Logger.withTag("SeedCredentialsJvmTest") + + private class Seed(val words: List) { + val keyManager = LocalKeyManager( + seed = MnemonicCode.toSeed(words, "").byteVector(), + chain = NodeParamsManager.chain, + remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub, + ) + val walletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey) + val nostrKey = StoredIdentity.nostrPrivateKeyOf(words) + val nostrPublicKey = nostrKey.nostrPublicKeyHex() + } + + private fun freshSeed() = Seed(MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist())) + + private val first = freshSeed() + private val second = freshSeed() + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-seed-credentials-store").toFile() + appDir = Files.createTempDirectory("mantra-seed-credentials-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + /** A device from before: the seeds on disk, and nothing said about their keys. */ + private fun writeSeedsOnly(vararg seeds: Seed) { + SeedManager.writeSeedToDisk( + phoenixGlobal, + EncryptedSeed.V2.encrypt(seeds.associate { it.walletId to it.words }), + overwrite = true, + ) + } + + private fun wallets() = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable") + private fun credentials() = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("credentials unreadable") + private fun credentialsFileBytes(): ByteArray? = + File(SeedManager.getDatadir(phoenixGlobal.ctx).toString(), "nostr-credentials.dat").takeIf { it.exists() }?.readBytes() + + @Test + fun `every seed without a credential gets one, in one write`() { + writeSeedsOnly(first, second) + assertNull(credentialsFileBytes(), "the device from before had no credentials file") + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + + val written = assertIs(result) + assertEquals(2, written.count) + val expected = mapOf( + first.nostrPublicKey to NostrCredential.Secret(first.nostrKey), + second.nostrPublicKey to NostrCredential.Secret(second.nostrKey), + ) + assertEquals(expected, written.credentials, "the map the listing is handed") + assertEquals(expected, credentials(), "and the file") + } + + @Test + fun `a device already repaired is not written to again`() { + writeSeedsOnly(first) + SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + val bytesAfterRepair = credentialsFileBytes() + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + + val notNeeded = assertIs(result) + assertEquals(mapOf(first.nostrPublicKey to NostrCredential.Secret(first.nostrKey)), notNeeded.credentials) + assertTrue(bytesAfterRepair.contentEquals(credentialsFileBytes()), "a rewrite would carry a fresh iv") + } + + @Test + fun `a device with no seeds needs nothing`() { + val result = SeedCredentials.reconcile(log, phoenixGlobal, emptyMap(), emptyMap()) + + assertIs(result) + assertNull(credentialsFileBytes()) + } + + /** The read-only upgrade the seed writer performs, done for a device where it was left undone. */ + @Test + fun `a public entry for a seed's key becomes a secret`() { + writeSeedsOnly(first) + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(mapOf(first.nostrPublicKey to NostrCredential.Public)), + ) + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()) + + val written = assertIs(result) + assertEquals(1, written.count) + assertEquals(mapOf(first.nostrPublicKey to NostrCredential.Secret(first.nostrKey)), credentials()) + } + + /** Other people's entries are left exactly where they were. */ + @Test + fun `bare keys already in the file are untouched`() { + writeSeedsOnly(first) + val bare = StoredIdentity.nostrSecret(fr.acinq.bitcoin.PrivateKey(Lightning.randomBytes(32))) + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(mapOf(bare.nostrPublicKey to NostrCredential.Secret(bare.privateKey))), + ) + + val written = assertIs(SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials())) + + assertEquals(1, written.count) + assertEquals( + mapOf( + bare.nostrPublicKey to NostrCredential.Secret(bare.privateKey), + first.nostrPublicKey to NostrCredential.Secret(first.nostrKey), + ), + credentials(), + ) + } + + /** + * The write fails -- here because the key store is locked, which is how it fails on + * a device whose keystore entry is gone -- and the listing must go on with what it + * read. A failed write must never hide a wallet. + */ + @Test + fun `a write that fails is reported with the map that was read`() { + writeSeedsOnly(first) + val wallets = wallets() + val read = credentials() + JvmKeyStore.lock() + + val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets, read) + + val failed = assertIs(result) + assertEquals(read, failed.credentials) + assertNull(credentialsFileBytes(), "nothing reached the disk") + // And the listing built on it still holds the wallet, by derivation. + val listed = assertIs(StoredIdentity.merge(wallets, failed.credentials)[first.walletId]) + assertEquals(first.nostrKey, listed.privateKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt index 5f962472..971b8f26 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt @@ -15,8 +15,8 @@ import kotlin.test.assertNotEquals * * The vector is NIP-06's own: twelve words, the key at `m/44'/1237'/0'/0/0`, and the * x-only public key. It pins that a wallet's `nostrPublicKey` is the same key an nsec - * import of that wallet's nostr secret would produce -- which is the whole basis of the - * duplicate check in `IdentityWriter`. + * import of that wallet's nostr secret would produce -- which is what attaches a seed + * to its credential, and what the duplicate check in `IdentityWriter` compares. */ class StoredIdentityJvmTest { @@ -43,6 +43,7 @@ class StoredIdentityJvmTest { val merged = StoredIdentity.merge( wallets = mapOf(wallet.walletId to wallet), credentials = mapOf( + nip06PublicKey to NostrCredential.Secret(nip06PrivateKey), bareKey.publicKey().xOnly().value.toHex() to NostrCredential.Secret(bareKey), readOnlyKey to NostrCredential.Public, ), @@ -51,6 +52,7 @@ class StoredIdentityJvmTest { assertEquals(3, merged.size) val mnemonic = assertIs(merged[wallet.walletId]) assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) + assertEquals(nip06PrivateKey, mnemonic.privateKey, "the credential's key, not a derivation") assertEquals(IdentityKind.Mnemonic, mnemonic.kind) val secret = assertIs(merged[bareKey.publicKey().xOnly().toWalletId()]) @@ -73,27 +75,47 @@ class StoredIdentityJvmTest { } /** - * The same npub as a wallet, imported as a bare key, gets a *different* id -- one - * hash is of the node key, the other of the nostr key -- so a map keyed by id holds - * both. That is not a bug in the merge; it is why the writers dedupe by public key. + * The normal state of a wallet since docs/multiple-profiles.md: its key is a secret + * in the credentials file, and the seed is attached to that entry. Listed once, under + * the wallet's id, with the credential's key -- not twice under two ids, which is what + * this map held before the credentials file was the list of profiles. */ @Test - fun `a wallet and its own nostr key as a bare secret do not collide by id`() { + fun `a secret whose key a seed derives is listed once, as the wallet, with the credential's key`() { val merged = StoredIdentity.merge( wallets = mapOf(wallet.walletId to wallet), credentials = mapOf(nip06PublicKey to NostrCredential.Secret(nip06PrivateKey)), ) - assertEquals(2, merged.size) - assertEquals(1, merged.values.map { it.nostrPublicKey }.toSet().size, "one npub, twice") + assertEquals(1, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PrivateKey, mnemonic.privateKey) + assertEquals(wallet, mnemonic.userWallet) } /** - * The one precedence the merge decides. A recovery phrase pasted over a public - * credential is written across two files -- the credential removed first, then the - * seed -- and a crash between the two would leave neither, never both; this is the - * rule for the "both" that is not supposed to happen, so that it lists the wallet - * rather than one npub twice. + * The state a device from before the repair is in, and the one a failed repair + * leaves: the seed is listed anyway, with its key derived from the words, so that a + * missing entry never hides a wallet. + */ + @Test + fun `a seed with no credential is listed with its key derived`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + credentials = emptyMap(), + ) + + assertEquals(1, merged.size) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PrivateKey, mnemonic.privateKey) + assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) + } + + /** + * A public entry for a key a seed derives is the state the seed writer's two-file + * upgrade leaves if it dies between its writes, and the one the repair upgrades at + * the next listing. Until then the wallet is listed by derivation, and the public + * entry is not listed at all: one npub, once. */ @Test fun `a public key a seed already derives is listed as the wallet only`() { @@ -103,7 +125,8 @@ class StoredIdentityJvmTest { ) assertEquals(1, merged.size) - assertIs(merged[wallet.walletId]) + val mnemonic = assertIs(merged[wallet.walletId]) + assertEquals(nip06PrivateKey, mnemonic.privateKey) } @Test diff --git a/docs/multiple-profiles.md b/docs/multiple-profiles.md index 49cd6ef9..2f6bedef 100644 --- a/docs/multiple-profiles.md +++ b/docs/multiple-profiles.md @@ -337,13 +337,14 @@ object SeedCredentials { } ``` -It runs after the seed file is read and the nostr-keys migration has run, and -before the credentials file is read for the listing — so the listing sees the -repaired file. On a device with no seeds it does nothing; on a device already -repaired it does nothing; on the first launch after this phase it writes once. -`Failed` is logged, not surfaced: this is a repair of something the app can still -work around, and a listing that goes red because a background write failed would -be worse than the derivation it was replacing. +It runs after both files are read and the nostr-keys migration has run, and the +listing is built from what it returns — the repaired map, or the one read if the +write failed — so the listing sees the repaired file without decrypting it twice. +On a device with no seeds it does nothing; on a device already repaired it does +nothing; on the first launch after this phase it writes once. `Failed` is logged, +not surfaced: this is a repair of something the app can still work around, and a +listing that goes red because a background write failed would be worse than the +derivation it was replacing. ### The listing diff --git a/docs/npub-sign-in.md b/docs/npub-sign-in.md index 91eda7e9..13672418 100644 --- a/docs/npub-sign-in.md +++ b/docs/npub-sign-in.md @@ -29,6 +29,7 @@ below says so: | `signInToProfile` "made idempotent by pubkey" | done, and tested by signing in twice and counting one; the sign-in view model's three writers now go through one shared write-and-map | | the round trip "with the network faked at the repository" | with the *database* real: an in-memory Room under the app's own DAOs and a real `NotaryViewModel`, because "nothing was signed" is about what is not in the tables — plus a contrast case the plan did not ask for, the same harness as a signing identity producing a key package, so the assertions are known to bite | | `use_a_different_key` as a new string | it existed: the sign-in screen's own "use a different key" button. One string, two screens, one meaning | +| one entry per pubkey *across both files*, with `merge` listing the seeds and adding the credentials | **superseded** by [multiple-profiles.md](./multiple-profiles.md), Phase 1: the credentials file is the list of profiles and a seed is attached to the entry its key derives, so a seed's key *is* in the file; `merge` lists the credentials and attaches the seeds. The upgrade table gains a row — a phrase over a bare secret attaches — and `NotACredential` becomes `WalletAttached` | One thing found on the way that is in no phase: a compose test that looks for a floating action button's label by text has to search the unmerged tree, because diff --git a/docs/nsec-sign-in.md b/docs/nsec-sign-in.md index 0b02b703..f91e0eb4 100644 --- a/docs/nsec-sign-in.md +++ b/docs/nsec-sign-in.md @@ -30,6 +30,7 @@ it: | the one hop, unspecified where | in the sync pump, after `saveNostrEvent`, with a per-account set so five indexers answering with the same kind 10002 make one hop | | forget: key, prefs, metadata, active identity | plus the account's unsigned rows (`forgetLocalAccount`), which the plan did not list: the kind 0 that made it a local account, and anything queued that can now never be signed. Published events and the profile cache stay | | assert `platformStartupLogic` was not called | `identity.business == null` and the jvm `BusinessManager.businessFlow` empty afterwards — the observable fact rather than the call | +| the dedupe rule: a seed's key is kept out of the key file, and a seed whose key is here as a bare secret is `SeedAlreadyExists` | **superseded** by [multiple-profiles.md](./multiple-profiles.md), Phase 1: a seed's key is a `Secret` credential like any other, written when the seed is and repaired in for every seed already here; a bare secret's phrase *attaches* the wallet rather than being refused; the identity's key is read from the credential, with the node's as a cross-check | Two things found on the way that are not in any phase. `DataStoreManager` caches each id's preferences in a companion object for the life of the process, so a test From d6d87ed208443b642a0ab6bda6b93a387c490c7d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 15:48:55 +0200 Subject: [PATCH 44/71] docs: record Phase 0 and Phase 2 of the profiles pull Phase 0 needed one thing this plan did not predict in detail: the worktree's library checkout sat at 01489b8, an older master whose object store did not even hold the pinned 84cc44c, while its nested chain was already at the pinned four. One fetch from the main checkout's submodule and a detached checkout made the tree clean. The rewrite was rebuilt from the fork point and produced the same forty-nine rewritten commits as the dry run, hash for hash: filter-branch with the normaliser is deterministic for the same input, so the dry run's numbers are the run's numbers rather than an estimate of them. Pushing the removal to origin/mantra is the user's step and is recorded as still open. Phase 2 landed a5ff2641 and 3008137e as 44324c90 and d957ee8d, both clean picks. The residual between the trees is 136 files before and after -- the dry run's 135 plus this document -- and no file's residual moved. Both compilers clean, jvmTest 826 -> 837, testDebugUnitTest 413, every audit budget met, and nothing under composeApp/schemas/ changed after a full build. The release note the phase owes is written out in one line, since it is the one downgrade hazard in the pull. Co-Authored-By: Claude Opus 5 --- docs/curated-to-mantra-profiles.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md index df663e8e..ac5a29e9 100644 --- a/docs/curated-to-mantra-profiles.md +++ b/docs/curated-to-mantra-profiles.md @@ -332,6 +332,15 @@ Nothing from Curare lands. Two things to settle first: Exit: `git status` clean in the scratch worktree; `origin/mantra` at `776455ec` or later. +**Done 2026-09-13**, on the branch that carries this plan (`5f2414dc`, itself on +`776455ec`): the worktree's library was at `01489b8` and its nested chain already at the +pinned four, so one fetch from the main checkout's submodule and a detached checkout of +`84cc44c` made the tree clean. The rewrite was rebuilt from `ba26c0b1` in 104 s and +produced the same forty-nine rewritten commits as the dry run, hash for hash — the +normaliser and `filter-branch` are deterministic given the same input, which makes the +dry run's numbers the run's numbers. Item 1 is still the user's: `origin/mantra` was at +`0fa807a6` when this landed, and the removal goes up with the pull. + ### Phase 1 — the dry run **Run on 2026-09-13, against `curated/curated` at `29027f2b`, on a scratch worktree of @@ -383,6 +392,16 @@ Exit: a device with a seed lists once, from the credential, with the node's key cross-check; `SeedCredentialsJvmTest` and the rewritten halves of `IdentityWriterJvmTest` and `StoredIdentityJvmTest` green; release note drafted. +**Built 2026-09-13**, `44324c90` and `d957ee8d`, both clean picks. Exactness: the +residual is 136 files before and after (the dry run's 135 plus this document), nothing +moved. Both compilers clean; `jvmTest` 826 → 837 (the eleven are `SeedCredentialsJvmTest` +and the rewritten writer and listing tests); `testDebugUnitTest` 413; audit all budgets +met; nothing under `composeApp/schemas/` changed, as expected of a phase with no +migration. The release note this phase owes, in one line: *the first launch after this +build writes a credential for every recovery phrase on the device; going back to an +older build afterwards lists each such profile twice until the next upgrade, and loses +nothing.* + ### Phase 3 — the switch, the precedence, the switcher, the entrances `9b3a1927`, `00fc9969`, `25464595`, `2f420230`. Upstream ships 2 with 3 and 4 with 5; From 84ac84ccafbe20b99d713d60d3263ecbe026b264 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 00:27:06 +0200 Subject: [PATCH 45/71] feat(identity): a switch that tears down what it should Phase 2 of docs/multiple-profiles.md. The transition, made correct, with nothing yet calling it from a screen: the view model, one manager, and three one-line actuals. switchToWallet becomes switchToIdentity, and does what a switch is: remember which one, set startWalletImmediately back to true -- a switch is the user saying which one, and the flag was only ever the user saying "show me the list"; nothing set it back before because nothing could switch -- clear the active identity, and stop the node of the profile being left, if it had one. The identity is cleared before the node is stopped, so that every collector that could reach the business is cancelled before the stop runs. The test is business != null, not the kind: whether the profile being left has a node behind it is the fact, and the kind is how it currently comes to be true. stopPlatformBusiness is an expect beside updateBusinessActiveInUI, for the reason that one is: BusinessManager is a per-platform object. Its three actuals call stopBusiness, which existed on every platform and nothing called. The manager is injected into the view model as a function so that the branch can be pinned without a node. RelaysSocketManager.observeActiveUserId becomes a child of collectLatest -- the shape the pumps and the notary already had. It used to keep one job per pubkey on its own scope and cancel only the job for the pubkey being started, which meant a switch left the previous identity's observer running: two observers feeding updateRelayPools, and the one pool following whichever relay list emitted last. The map goes; a null identity closes nothing, since the pool is shared by pumps a null identity has already cancelled, and the next identity's list replaces it through changeRelays as it always did. The scope is injectable and relayUrls is exposed, both for the test. The sign-in and create tails keep their own navigate beside the observer's, now with a comment saying why: the navigation state is a StateFlow, an update to a state equal to the current one emits nothing, and the explicit navigation is what guarantees the stack moves even on the day the state does not. Tests: IdentitySwitchJvmTest, through the real SovereignWalletViewModel with a real notary and navigation machine on an in-memory database -- A open and a kind 1 queued for A is signed; switch to B, the identity clears, the machine goes to startup, B is activated the way startup does and routed from its account; a kind 1 queued for A now waits three seconds unsigned, and goes out when A is back. Leaving a profile with a PhoenixBusiness behind it -- constructible without a node, since everything in it is lazy -- stops that node once; leaving nothing, or a bare key, stops nothing. RelaysSocketManagerSwitchJvmTest over a fake relay repository and fake sockets: after the switch the pool holds B's relays, a re-emission of A's list changes nothing -- the line that fails against the old observer -- and an identity whose list is still empty leaves the pool as it was, which is the behaviour updateRelayPools has always had for an empty list. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@9b3a19278ffce7a191c275d480a2ae6569d15e9d --- .../view/model/NavigationViewModel.android.kt | 4 + .../network/relays/RelaysSocketManager.kt | 50 ++-- .../SovereignWalletStartupScreen.kt | 2 +- .../ui/composable/navigation/MantraNavHost.kt | 11 +- .../ui/view/model/SovereignWalletViewModel.kt | 38 ++- .../ui/view/model/NavigationViewModel.ios.kt | 4 + .../ui/view/model/NavigationViewModel.jvm.kt | 4 + .../compose/identity/IdentitySwitchJvmTest.kt | 229 ++++++++++++++++++ .../RelaysSocketManagerSwitchJvmTest.kt | 179 ++++++++++++++ 9 files changed, 490 insertions(+), 31 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt index 3a90eb28..35c8a017 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt @@ -13,6 +13,10 @@ actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) } +actual fun stopPlatformBusiness(walletId: WalletId) { + BusinessManager.stopBusiness(walletId) +} + actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { return SeedManager.loadAndDecrypt( phoenixGlobal diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index 7d796b02..c79e7f7c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -2,7 +2,6 @@ package press.mantra.compose.network.relays import press.mantra.compose.network.dto.mapToRelayDTO import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd @@ -13,7 +12,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -32,9 +30,10 @@ class RelaysSocketManager( private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory, private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository, private val relayRepository: press.mantra.compose.repository.RelayRepository, + /** Injectable so that a test can drive the identity observer on a dispatcher it controls. */ + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), ) : LiveSubscriptionTransport, EventPublishTransport { val logger = Logger.withTag("RelaysSocketManager") - private val scope = CoroutineScope(Dispatchers.IO) private val relayPoolsMutex = Mutex() /** @@ -56,35 +55,36 @@ class RelaysSocketManager( observeActiveUserId() } - private val observeRelayJobs = mutableMapOf() - + /** + * Follows the active identity's relay list into the pool, as a child of `collectLatest` + * so that the next identity cancels it -- the shape the pumps and the notary already + * have. + * + * It used to be a job per pubkey on [scope], replaced only when the *same* pubkey came + * round again, which meant a switch left the previous identity's observer running: + * two observers feeding [updateRelayPools], and the one pool following whichever relay + * list emitted last. A null identity closes nothing -- the pool is shared by pumps a + * null identity has already cancelled -- and the next identity's list replaces it + * through `changeRelays`, as it always did. + */ private fun observeActiveUserId() = scope.launch { activeIdentityStateFlow.collectLatest { identity -> - if (identity == null) { - // TODO: Cancel all pending jobs? - } - // The identity carries its pubkey, so this no longer waits on the node's key - // manager to fill in. The relay observer is still a job on `scope` keyed by - // pubkey and replaced per pubkey, exactly as before. - identity?.nostrPublicKey?.let { pubkey -> - observeRelayJobs[pubkey]?.cancel() - observeRelayJobs[pubkey] = observeRelays(pubkey) + val publicKey = identity?.nostrPublicKey ?: return@collectLatest + try { + relayRepository.observePublicKeyRelays(publicKey = publicKey).collect { relays -> + val userRelays = relays.filter { it.type == "user" }.map { it.mapToRelayDTO() } + updateRelayPools(regularRelays = userRelays) + } + } catch (error: CancellationException) { + logger.d { "Relay observation for $publicKey cancelled" } + throw error } } } - private fun observeRelays(publicKey: String): Job = - scope.launch { - try { - relayRepository.observePublicKeyRelays(publicKey = publicKey).collect { relays -> - val userRelays = relays.filter { it.type == "user" }.map { it.mapToRelayDTO() } - updateRelayPools(regularRelays = userRelays) - } - } catch (error: CancellationException) { - logger.w(throwable = error) { "Relay observation cancelled" } - } - } + /** The urls the pool currently holds. For the switch test; nothing in the app reads it. */ + val relayUrls: Set get() = relayPool.relays.map { it.url }.toSet() private suspend fun updateRelayPools(regularRelays: List?) { relayPoolsMutex.withLock { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 39c7db66..3846d9bc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -135,7 +135,7 @@ fun SovereignWalletStartupScreen( walletsMetadata = availableWalletMetadata, activeWalletId = null, onWalletClick = { - sovereignWalletViewModel.switchToWallet( + sovereignWalletViewModel.switchToIdentity( it.id ); loadingIdentity = it }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 5b6a684f..e4ec030a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -523,7 +523,7 @@ fun MantraNavHost( sovereignWalletViewModel.loadSovereignData(walletId) sovereignWalletViewModel.listIdentities { - sovereignWalletViewModel.switchToWallet(walletId) + sovereignWalletViewModel.switchToIdentity(walletId) navController.navigate( route = SovereignWalletStartupRoute ) @@ -719,10 +719,17 @@ fun MantraNavHost( // startup. Startup finds the new identity, activates it, and the navigation // machine takes it from the placeholder account the sign-in planted. The // form is popped so that back does not return to a field holding a secret. + // + // The navigate here looks redundant with the observer's -- switching clears + // the identity, and a null identity is StartupPhoenix, which the observer + // above navigates to with the same popUpTo(0). It is not: the navigation + // state is a StateFlow, and an update to a state equal to the current one + // emits nothing. This is what guarantees the stack moves even on the day + // the state does not. Leave both. onSignedIn = { walletId -> sovereignWalletViewModel.loadSovereignData(walletId) sovereignWalletViewModel.listIdentities { - sovereignWalletViewModel.switchToWallet(walletId) + sovereignWalletViewModel.switchToIdentity(walletId) navController.navigate( route = SovereignWalletStartupRoute ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 1ab201d7..bf514615 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -57,6 +57,13 @@ sealed class WritingSeedState { } expect fun updateBusinessActiveInUI(walletId: WalletId) + +/** + * Stops the node running for [walletId], if one is. The inverse of what the startup + * screen's mnemonic branch starts; an `expect` for the reason [updateBusinessActiveInUI] + * is, which is that `BusinessManager` is a per-platform object. + */ +expect fun stopPlatformBusiness(walletId: WalletId) expect fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult expect fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> @@ -74,6 +81,8 @@ expect suspend fun saveAvailableWalletMeta( class SovereignWalletViewModel( val phoenixGlobal: PhoenixGlobal, // We might end up only using the machankuraWalletRepository in the future where we send the walletId in each request. + /** Injected so that a switch can be pinned in a test without a node to stop. */ + private val stopBusiness: (WalletId) -> Unit = ::stopPlatformBusiness, ): ViewModel() { private val log = Logger.withTag("SovereignWalletViewModel") @@ -331,10 +340,33 @@ class SovereignWalletViewModel( // } // } - /** Clears the active identity and signals the startup screen to load the given [walletId]. */ - fun switchToWallet(walletId: WalletId) { - _desiredWalletId.value = walletId + /** + * Makes [id] the identity to open next and clears the active one, which is the whole + * of a switch: the navigation observer sends a null identity to startup, startup + * opens [id] through the lock gate, and the machine routes it from its account. + * Everything reading [activeIdentity] is cancelled by the null and rebuilt by the + * activation -- the notary, the pumps, the live subscriptions, the relay observer. + * See docs/multiple-profiles.md, Phase 2. + * + * [startWalletImmediately] goes back to true: a switch is the user saying which one, + * and the flag was only ever the user saying *show me the list*. Nothing set it back + * before because nothing could switch. + * + * The identity is cleared **before** the node is stopped: clearing cancels every + * collector that could reach the business, so the stop finds nothing reading it. + * The test is `business != null`, not the kind -- whether the profile being left has + * a node behind it is the fact, and the kind is how it currently comes to be true. + * `previous.id` is the wallet's id when a wallet is attached, which is why the + * manager can take it. + */ + fun switchToIdentity(id: WalletId) { + val previous = _activeIdentity.value + _desiredWalletId.value = id + startWalletImmediately.value = true _activeIdentity.value = null + if (previous?.business != null) { + viewModelScope.launch(Dispatchers.IO) { stopBusiness(previous.id) } + } } /** Clears the active identity. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */ diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt index 6d4e242c..e604d416 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt @@ -15,6 +15,10 @@ actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) } +actual fun stopPlatformBusiness(walletId: WalletId) { + BusinessManager.stopBusiness(walletId) +} + actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { return SeedManager.loadAndDecrypt( phoenixGlobal diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt index e076aae7..acea8fb4 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -23,6 +23,10 @@ actual fun updateBusinessActiveInUI(walletId: WalletId) { BusinessManager.updateBusinessActiveInUI(walletId) } +actual fun stopPlatformBusiness(walletId: WalletId) { + BusinessManager.stopBusiness(walletId) +} + actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { return SeedManager.loadAndDecrypt( phoenixGlobal diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt new file mode 100644 index 00000000..ad4017a0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt @@ -0,0 +1,229 @@ +package press.mantra.compose.identity + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.model.NotaryViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.state.NavigationUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * A switch, end to end -- Phase 2 of docs/multiple-profiles.md -- through the real + * `SovereignWalletViewModel`, with a real notary and a real navigation machine on an + * in-memory database. + * + * The assertion a switch is for: the notary signs as whoever is open and nobody else. + * Two bare keys, A and B; A active, and a kind 1 queued for A is signed. Switch to B -- + * the identity clears, the machine goes to startup, B is activated the way startup + * does -- and a kind 1 queued for A now waits. Switch back, and it goes out. + * + * And the one effect a switch has outside the identity flow: the node of the profile + * being left is stopped, once, and only when there was one. The manager is injected as + * a recorder, since a node cannot be started here; a `PhoenixBusiness` can be built + * without one, because everything in it is lazy, and that is enough to make the branch + * true. + */ +class IdentitySwitchJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + private lateinit var globalPrefs: GlobalPrefs + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val chatRepository = DatabaseChatRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val log = Logger.withTag("IdentitySwitchJvmTest") + private val keyA = PrivateKey(Lightning.randomBytes(32)) + private val keyB = PrivateKey(Lightning.randomBytes(32)) + + private val stopped = mutableListOf() + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-switch-store").toFile() + appDir = Files.createTempDirectory("mantra-switch-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + globalPrefs = GlobalPrefs( + PreferenceDataStoreFactory.createWithPath { + computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") + } + ) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private suspend fun signIn(key: PrivateKey): WalletId { + val written = assertIs( + IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, key, isTorEnabled = false, customElectrumServer = null) + ) + nostrRepository.signInToProfile(key.nostrPublicKeyHex()) + return written.id + } + + /** What the startup screen's bare-key branch builds. */ + private fun activated(stored: StoredIdentity.NostrSecret): Identity { + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + return Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + } + + private suspend fun queueNote(key: PrivateKey, content: String) { + val now = Clock.System.now() + db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = key.nostrPublicKeyHex(), kind = 1, tags = emptyArray(), content = content, createdAt = now, updatedAt = now, savedAt = now) + ) + } + + private suspend fun unsignedFor(key: PrivateKey) = + nostrRepository.observeUnsignedNostrEvents(key.nostrPublicKeyHex()).first() + + @Test + fun `the notary signs as whoever is open and nobody else`() = runBlocking { + val idA = signIn(keyA) + val idB = signIn(keyB) + + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { sovereign.availableIdentities.first { it.size == 2 } } + val storedA = assertIs(sovereign.availableIdentities.value[idA]) + val storedB = assertIs(sovereign.availableIdentities.value[idB]) + + val navigation = NavigationViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + initialNavigationUIState = NavigationUIState.Loading(""), + nostrRepository = nostrRepository, + scope = scope, + ) + NotaryViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + + // A is open, and what A queues goes out. + sovereign.setActiveIdentity(activated(storedA)) + queueNote(keyA, "first, as A") + withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(keyA.nostrPublicKeyHex()).first { it.isEmpty() } } + + // The switch: the identity clears, the machine goes to startup, and startup opens B. + sovereign.switchToIdentity(idB) + assertEquals(idB, sovereign.desiredWalletId.value) + assertNull(sovereign.activeIdentity.value) + assertTrue(sovereign.startWalletImmediately.value, "a switch is the user saying which one") + withTimeout(15_000) { navigation.navigationUIState.first { it is NavigationUIState.StartupPhoenix } } + sovereign.setActiveIdentity(activated(storedB)) + val routed = withTimeout(15_000) { + navigation.navigationUIState.first { it is NavigationUIState.UnqueuedProfileSynchronization } + } + assertEquals(keyB.nostrPublicKeyHex(), (routed as NavigationUIState.UnqueuedProfileSynchronization).unsignedNostrEvent.pubKey) + + // What A queues while B is open waits for A. + queueNote(keyA, "second, while B is open") + delay(3_000) + assertEquals(1, unsignedFor(keyA).size, "A's note is not B's to sign") + assertNull(unsignedFor(keyA).single().signedAt) + + // And goes out when A is back. + sovereign.switchToIdentity(idA) + sovereign.setActiveIdentity(activated(storedA)) + withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(keyA.nostrPublicKeyHex()).first { it.isEmpty() } } + + assertEquals(emptyList(), stopped, "bare keys have no node to stop") + assertTrue(BusinessManager.businessFlow.value.isEmpty()) + } + + @Test + fun `leaving a profile with a node behind it stops that node, once`() = runBlocking { + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + val walletId = WalletId("ab".repeat(20)) + val withNode = Identity.signing( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyA, + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId), + business = PhoenixBusiness(phoenixGlobal), + ) + val other = StoredIdentity.nostrSecret(keyB).id + + sovereign.setActiveIdentity(withNode) + sovereign.switchToIdentity(other) + withTimeout(5_000) { while (stopped.isEmpty()) delay(20) } + + assertEquals(listOf(walletId), stopped) + assertNull(sovereign.activeIdentity.value) + assertEquals(other, sovereign.desiredWalletId.value) + } + + @Test + fun `switching from nothing, or from a bare key, stops nothing`() = runBlocking { + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val target = StoredIdentity.nostrSecret(keyB).id + + sovereign.switchToIdentity(target) + sovereign.setActiveIdentity(activated(StoredIdentity.nostrSecret(keyA))) + sovereign.switchToIdentity(target) + delay(500) + + assertEquals(emptyList(), stopped) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt new file mode 100644 index 00000000..ad29f97c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt @@ -0,0 +1,179 @@ +package press.mantra.compose.network.relays + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import okio.Path.Companion.toPath +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import press.mantra.compose.database.model.Relay +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.network.sockets.NostrSocketClient +import press.mantra.compose.network.sockets.NostrSocketClientFactory +import press.mantra.compose.network.sockets.SocketConnectionClosedCallback +import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback +import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback +import press.mantra.compose.repository.CachingImportRepository +import press.mantra.compose.repository.RelayRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The relay observer follows the identity that is open, and stops following the one + * that was. + * + * Before Phase 2 of docs/multiple-profiles.md it kept one observer per pubkey and only + * ever cancelled the one for the pubkey being started, so after a switch the previous + * identity's observer was still feeding the pool -- and the pool followed whichever + * relay list emitted last. The regression this pins is exactly that: after switching + * from A to B, a re-emission of A's relay list changes nothing. + */ +class RelaysSocketManagerSwitchJvmTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val scope = CoroutineScope(Job() + Dispatchers.Default) + + private val keyA = PrivateKey(ByteVector32("0a".repeat(32))) + private val keyB = PrivateKey(ByteVector32("0b".repeat(32))) + + /** Each identity's relay list, as the database would emit it. */ + private val relayLists = mutableMapOf>>() + + private val relayRepository = object : RelayRepository { + override suspend fun observePublicKeyRelays(publicKey: String): Flow> = + relayLists.getOrPut(publicKey) { MutableStateFlow(emptyList()) } + } + + private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath { + temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() + } + + private fun identity(key: PrivateKey) = Identity.signing( + id = key.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = key, + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + business = null, + ) + + private fun relays(key: PrivateKey, vararg urls: String) = + urls.map { Relay(publicKey = key.nostrPublicKeyHex(), type = "user", url = it, read = true, write = true) } + + private fun emit(key: PrivateKey, vararg urls: String) { + relayLists.getOrPut(key.nostrPublicKeyHex()) { MutableStateFlow(emptyList()) }.value = relays(key, *urls) + } + + @AfterTest + fun tearDown() { + scope.cancel() + } + + private suspend fun RelaysSocketManager.awaitRelays(expected: Set) = withTimeout(5_000) { + while (relayUrls != expected) delay(20) + } + + @Test + fun `after a switch the pool follows the new identity, and the old one's list no longer moves it`() = runBlocking { + val activeIdentity = MutableStateFlow(null) + emit(keyA, "wss://a-one.example", "wss://a-two.example") + emit(keyB, "wss://b.example") + val manager = RelaysSocketManager( + activeIdentityStateFlow = activeIdentity, + nostrSocketClientFactory = SwitchFakeSocketClientFactory(), + cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, + relayRepository = relayRepository, + scope = scope, + ) + + activeIdentity.value = identity(keyA) + manager.awaitRelays(setOf("wss://a-one.example", "wss://a-two.example")) + + // The switch: cleared, then the next one. + activeIdentity.value = null + activeIdentity.value = identity(keyB) + manager.awaitRelays(setOf("wss://b.example")) + + // A's list moves on -- as it would when a sync for A's rows lands -- and the pool + // must not care: A is not open. + emit(keyA, "wss://a-three.example") + delay(500) + assertEquals(setOf("wss://b.example"), manager.relayUrls) + + // B's list moves on, and the pool does follow. + emit(keyB, "wss://b.example", "wss://b-two.example") + manager.awaitRelays(setOf("wss://b.example", "wss://b-two.example")) + } + + @Test + fun `an identity whose list is still empty leaves the pool as it was`() = runBlocking { + val activeIdentity = MutableStateFlow(null) + emit(keyA, "wss://a.example") + val manager = RelaysSocketManager( + activeIdentityStateFlow = activeIdentity, + nostrSocketClientFactory = SwitchFakeSocketClientFactory(), + cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY, + relayRepository = relayRepository, + scope = scope, + ) + activeIdentity.value = identity(keyA) + manager.awaitRelays(setOf("wss://a.example")) + + // B has no relay list yet: nothing is known to replace A's with, and the pool + // keeps what it had -- the behaviour `updateRelayPools` has always had for an + // empty list, unchanged by the switch. + activeIdentity.value = null + activeIdentity.value = identity(keyB) + delay(500) + assertEquals(setOf("wss://a.example"), manager.relayUrls) + + emit(keyB, "wss://b.example") + manager.awaitRelays(setOf("wss://b.example")) + } +} + +private class SwitchFakeSocketClientFactory : NostrSocketClientFactory { + override fun create( + wssUrl: String, + incomingCompressionEnabled: Boolean, + onSocketConnectionOpened: SocketConnectionOpenedCallback?, + onSocketConnectionClosed: SocketConnectionClosedCallback?, + onSocketConnectionReopened: SocketConnectionReopenedCallback?, + ): NostrSocketClient = SwitchFakeNostrSocketClient(wssUrl) +} + +private class SwitchFakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient { + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 8) + override val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() + override var autoReconnect: Boolean = false + override suspend fun close() = Unit + override suspend fun ensureSocketConnectionOrThrow() = Unit + override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) = Unit + override suspend fun sendAUTH(signedEvent: JsonObject) = Unit + override suspend fun sendCLOSE(subscriptionId: String) = Unit + override suspend fun sendCOUNT(data: JsonObject): String = "unused" + override suspend fun sendEVENT(signedEvent: JsonObject) = Unit + override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit +} From 73a0e5b9c27178e473883fd3077f2c9bfe192be2 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 00:43:21 +0200 Subject: [PATCH 46/71] feat(identity): which profile opens, on launch and after a switch Phase 3 of docs/multiple-profiles.md. The startup screen learns to agree with the switch. The table that picks what to open is lifted out of the screen's remember into StartupChoice.resolve, where it can be read and tested, because its order was the whole decision and it was wrong in the one way a switch would hit: "!startWalletImmediately -> null" sat above "desiredWalletId != null", and nothing ever set the flag back to true. So after the first visit to the selector, every sign-in on that device landed on the selector instead of the profile it had just signed in. Two rows move: what a switch or a sign-in named goes above the user's earlier "show me the list", since it is the more specific instruction; and the single-identity row drops below it, since when both are set they name the same thing. The default is written. GlobalPrefs.getDefaultWallet has been read by startup since Phoenix and saved by nothing in this app, so a cold boot with two profiles was the selector every time with no memory of which one was open. setActiveIdentity now saves the id it activates -- every activation goes through it, startup for all three kinds and the two tails through startup -- so the default is always the profile most recently open. The two forget tails clear it, since the default is the one memory of an identity that would otherwise outlive it. Both writes are wrapped: a default that could not be recorded is a selector on the next boot, not a crash now. The screen reads the saved value as a default only when it names a wallet, which is the one thing left to decide at the call site. The startup screen's literals go to the catalogue, and "wallet" goes with them except in the one place a wallet is what is starting: "Starting wallet" is shown from StartupViewState.StartingBusiness, which only the branch with a wallet attached reaches, and stays. The rest become "Preparing profiles", "Opening profile", "Decrypting", "Loading preferences", "Unlock to continue" and "Could not load the profiles on this device"; the selector's title is "Choose a profile", and select_a_wallet goes. Tests: StartupChoiceTest in commonTest, one case per row and the two orderings this phase is for -- a desired id opens even after the user once asked for the list, and one identity with the list asked for still shows it. IdentitySwitchJvmTest gains the cold boot: activate A then B, and a fresh view model over the same directory resolves B without asking; forget the default, and it resolves nothing. Found on the way and recorded in the test: DataStoreManager caches the global preferences once per process, bound to whichever directory was current when the first test in the JVM asked -- the same trap the nsec plan recorded for user preferences -- so the test reads the default through the view model's own instance rather than one of its own. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@00fc99699564d30ece5773deb1d97d031e47afbb --- .../composeResources/values/strings.xml | 10 ++- .../SovereignWalletStartupScreen.kt | 53 +++++++++------ .../ui/composable/navigation/MantraNavHost.kt | 2 + .../ui/view/model/SovereignWalletViewModel.kt | 27 +++++++- .../compose/ui/view/state/StartupChoice.kt | 45 +++++++++++++ .../ui/view/state/StartupChoiceTest.kt | 66 +++++++++++++++++++ .../compose/identity/IdentitySwitchJvmTest.kt | 52 +++++++++++++++ 7 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1cee3dcc..afcbc220 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -209,7 +209,6 @@ Search member functionality Search message functionality will be here. See how deep the rabbit hole goes - Select a wallet Send message Share profile Shared key @@ -470,4 +469,13 @@ You said you stored it New note + + Choose a profile + Could not load the profiles on this device + Decrypting… + Opening profile… + Preparing profiles… + Starting wallet… + Unlock to continue diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 3846d9bc..a0933d79 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -31,9 +31,17 @@ import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.choose_a_profile +import mantra.composeapp.generated.resources.could_not_load_the_profiles_on_this_device +import mantra.composeapp.generated.resources.decrypting +import mantra.composeapp.generated.resources.loading_preferences import mantra.composeapp.generated.resources.lock_prompt_coming_soon -import mantra.composeapp.generated.resources.select_a_wallet +import mantra.composeapp.generated.resources.opening_profile +import mantra.composeapp.generated.resources.preparing_profiles +import mantra.composeapp.generated.resources.starting_wallet import mantra.composeapp.generated.resources.startup_error +import mantra.composeapp.generated.resources.unlock_to_continue +import press.mantra.compose.ui.view.state.StartupChoice @Composable fun SovereignWalletStartupScreen( @@ -65,12 +73,12 @@ fun SovereignWalletStartupScreen( when (listWalletState) { is ListWalletState.Init -> { LoadingDataIndicator( - text = "Decrypting..." + text = stringResource(Res.string.decrypting) ) } is ListWalletState.Error -> { ImplementationPendingScreen( - text = "Failed to load wallet data", + text = stringResource(Res.string.could_not_load_the_profiles_on_this_device), // Drawn inside the startup gate, which is the root of the stack. onNavigateBack = null, ) @@ -95,17 +103,17 @@ fun SovereignWalletStartupScreen( availableIdentities.isEmpty() -> { LaunchedEffect(Unit) { onNavigateToWalletLandingPage.invoke() } LoadingDataIndicator( - text = "Initializing..." + text = stringResource(Res.string.preparing_profiles) ) } availableWalletMetadata == null || defaultWallet.value == null -> { LoadingDataIndicator( - text = "Preparing wallet..." + text = stringResource(Res.string.preparing_profiles) ) } activeIdentity != null -> { LoadingDataIndicator( - text = "Opening wallet" + text = stringResource(Res.string.opening_profile) ) LaunchedEffect(Unit) { sovereignWalletViewModel.loadSovereignData(activeIdentity.id) @@ -115,16 +123,18 @@ fun SovereignWalletStartupScreen( else -> { when (val startupState = sovereignWalletStartupViewModel.state.value) { is press.mantra.compose.ui.view.model.StartupViewState.Init -> { + // The table is StartupChoice's, where it can be read and + // tested; the one thing decided here is that the saved default + // is only a default when it names a wallet. var loadingIdentity by remember { mutableStateOf( - when { - forceWalletId != null -> availableIdentities[forceWalletId] - !startWalletImmediately -> null - availableIdentities.size == 1 -> availableIdentities.entries.firstOrNull()?.value - desiredWalletId != null -> availableIdentities[desiredWalletId] - startWalletImmediately -> availableIdentities[defaultWallet.value] - else -> null - } + StartupChoice.resolve( + force = forceWalletId, + desired = desiredWalletId, + startImmediately = startWalletImmediately, + identities = availableIdentities, + default = defaultWallet.value as? WalletId, + ) ) } when (val stored = loadingIdentity) { @@ -144,7 +154,7 @@ fun SovereignWalletStartupScreen( topContent = { Spacer(Modifier.height(MaterialTheme.spacing.space800)) Text( - text = stringResource(Res.string.select_a_wallet), + text = stringResource(Res.string.choose_a_profile), style = MaterialTheme.typography.headlineSmall ) Spacer(Modifier.height(MaterialTheme.spacing.space200)) @@ -229,13 +239,16 @@ fun SovereignWalletStartupScreen( } } is press.mantra.compose.ui.view.model.StartupViewState.StartingBusiness -> { + // The one place the word "wallet" stays: only the branch with + // a wallet attached reaches this state, and what is starting is + // a Lightning node. A bare-key profile never sees it. LoadingDataIndicator( - text = "Starting wallet" + text = stringResource(Res.string.starting_wallet) ) } is press.mantra.compose.ui.view.model.StartupViewState.BusinessActive -> { LoadingDataIndicator( - text = "Opening wallet" + text = stringResource(Res.string.opening_profile) ) } is press.mantra.compose.ui.view.model.StartupViewState.Error -> { @@ -296,10 +309,10 @@ private fun BoxScope.LoadWallet( when (isScreenLockRequired.value) { null -> { - LoadingDataIndicator(text = "Loading preferences...") + LoadingDataIndicator(text = stringResource(Res.string.loading_preferences)) } true -> { - LoadingDataIndicator(text = "Unlock to continue") + LoadingDataIndicator(text = stringResource(Res.string.unlock_to_continue)) ScreenLockPrompt( walletId = identity.id, walletName = metadata.nameOrDefault(), @@ -311,7 +324,7 @@ private fun BoxScope.LoadWallet( ) } false -> { - LoadingDataIndicator(text = "Starting wallet...") + LoadingDataIndicator(text = stringResource(Res.string.opening_profile)) LaunchedEffect(Unit) { doLoadWallet(identity) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index e4ec030a..868e5c46 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -419,6 +419,7 @@ fun MantraNavHost( forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, onSignedOut = { + sovereignWalletViewModel.forgetDefaultIdentity() sovereignWalletViewModel.listIdentities { sovereignWalletViewModel.resetToSelector() } @@ -928,6 +929,7 @@ fun MantraNavHost( // identity to startup, which shows the selector -- or Landing, if this // was the last one. onForgotten = { + sovereignWalletViewModel.forgetDefaultIdentity() sovereignWalletViewModel.listIdentities { sovereignWalletViewModel.resetToSelector() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index bf514615..2a9dbe37 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -149,12 +149,37 @@ class SovereignWalletViewModel( listIdentities(onDone = {}) } - /** Makes [identity] the one the app runs as. Everything reading [activeIdentity] follows. */ + /** + * Makes [identity] the one the app runs as. Everything reading [activeIdentity] + * follows, and the id is saved as the default: every activation goes through here + * -- startup for all three kinds, the sign-in and create tails through startup -- so + * the default is always the profile most recently open, and a cold boot with several + * opens that one. It was read by startup and written by nothing, which made every + * cold boot with two profiles the selector. + */ fun setActiveIdentity(identity: Identity) { _activeIdentity.value = identity + rememberDefault { saveDefaultWallet(identity.id) } // scheduleAutoLock() } + /** + * Forgets which profile opens on launch. For the forget tails: the default is the + * one memory of an identity that would otherwise outlive it, and a null read is + * better than a miss that happens to be handled. + */ + fun forgetDefaultIdentity() { + rememberDefault { clearDefaultWallet() } + } + + /** A default that could not be written is a selector on the next boot, not a crash now. */ + private fun rememberDefault(write: suspend GlobalPrefs.() -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { getGlobalPrefs().write() } + .onFailure { log.e("could not record which profile is open", it) } + } + } + /** * Activates a profile with a wallet attached, once its node has started. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt new file mode 100644 index 00000000..389e61ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/StartupChoice.kt @@ -0,0 +1,45 @@ +package press.mantra.compose.ui.view.state + +import fr.acinq.phoenix.data.WalletId +import press.mantra.compose.identity.StoredIdentity + +/** + * Which profile the startup screen opens without asking, and when it asks instead. + * + * Lifted out of the screen's `remember` so the table can be read and tested: the + * order of these rows is the whole of the decision, and it was wrong in the one way a + * switch would hit. `!startImmediately` sat above `desired`, and nothing ever set the + * flag back to true, so after the first visit to the selector every sign-in on the + * device landed on the selector instead of the profile it had just signed in. + * See docs/multiple-profiles.md, Phase 3. + */ +object StartupChoice { + + /** + * The identity to open, or null to show the selector. + * + * @param force an id the host insisted on; nothing passes one today. + * @param desired the id a switch or a sign-in named. The most specific instruction, + * and the one a switch relies on, so it outranks the user's earlier "show me the + * list". + * @param startImmediately false when the user asked for the list -- from a forget + * tail, or the lock prompt's back button -- and true otherwise, which a switch + * sets it back to. + * @param default the last profile opened, saved at every activation, so that a cold + * boot resumes where the user was. + */ + fun resolve( + force: WalletId?, + desired: WalletId?, + startImmediately: Boolean, + identities: Map, + default: WalletId?, + ): StoredIdentity? = when { + force != null -> identities[force] + desired != null -> identities[desired] + !startImmediately -> null + identities.size == 1 -> identities.values.single() + default != null -> identities[default] + else -> null + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt new file mode 100644 index 00000000..1715d159 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/view/state/StartupChoiceTest.kt @@ -0,0 +1,66 @@ +package press.mantra.compose.ui.view.state + +import fr.acinq.phoenix.data.WalletId +import press.mantra.compose.identity.StoredIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The table, one case per row, and the two orderings Phase 3 of + * docs/multiple-profiles.md exists for: what a switch or a sign-in named outranks the + * user's earlier "show me the list", and one identity with "show me the list" still + * shows it, because the user asked. + */ +class StartupChoiceTest { + + private val a = StoredIdentity.NostrPublic(WalletId("aa".repeat(20)), "a1".repeat(32)) + private val b = StoredIdentity.NostrPublic(WalletId("bb".repeat(20)), "b2".repeat(32)) + private val two = mapOf(a.id to a, b.id to b) + + private fun resolve( + force: WalletId? = null, + desired: WalletId? = null, + startImmediately: Boolean = true, + identities: Map = two, + default: WalletId? = null, + ) = StartupChoice.resolve(force, desired, startImmediately, identities, default) + + @Test + fun `a forced id wins over everything`() { + assertEquals(a, resolve(force = a.id, desired = b.id, startImmediately = false, default = b.id)) + } + + @Test + fun `what a switch or a sign-in named is opened, even after the user once asked for the list`() { + assertEquals(b, resolve(desired = b.id, startImmediately = false)) + } + + @Test + fun `the user asking for the list is honoured over a default and over a single identity`() { + assertNull(resolve(startImmediately = false, default = a.id)) + assertNull(resolve(startImmediately = false, identities = mapOf(a.id to a))) + } + + @Test + fun `one identity opens without asking`() { + assertEquals(a, resolve(identities = mapOf(a.id to a))) + } + + @Test + fun `the last profile opened is the one a cold boot opens`() { + assertEquals(b, resolve(default = b.id)) + } + + @Test + fun `a default the list no longer has, or no default, shows the list`() { + assertNull(resolve(default = WalletId("cc".repeat(20)))) + assertNull(resolve()) + } + + @Test + fun `an id that is not listed opens nothing`() { + assertNull(resolve(desired = WalletId("cc".repeat(20)))) + assertNull(resolve(force = WalletId("cc".repeat(20)))) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt index ad4017a0..8a9aed7b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt @@ -34,6 +34,8 @@ import press.mantra.compose.ui.view.model.NavigationViewModel import press.mantra.compose.ui.view.model.NotaryViewModel import press.mantra.compose.ui.view.model.SovereignWalletViewModel import press.mantra.compose.ui.view.state.NavigationUIState +import press.mantra.compose.ui.view.state.StartupChoice +import fr.acinq.phoenix.data.EmptyWalletId import java.io.File import java.nio.file.Files import kotlin.test.AfterTest @@ -60,6 +62,10 @@ import kotlin.time.Clock * a recorder, since a node cannot be started here; a `PhoenixBusiness` can be built * without one, because everything in it is lazy, and that is enough to make the branch * true. + * + * Then Phase 3's half: every activation saves which profile is open, so that a cold + * boot -- a fresh view model over the same directory -- resolves to it without asking; + * and a forget tail clears it, so the boot after shows the list. */ class IdentitySwitchJvmTest { @@ -214,6 +220,52 @@ class IdentitySwitchJvmTest { assertEquals(other, sovereign.desiredWalletId.value) } + @Test + fun `the last profile opened is the one a cold boot resolves, until it is forgotten`() = runBlocking { + val idA = signIn(keyA) + val idB = signIn(keyB) + val first = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { first.availableIdentities.first { it.size == 2 } } + val storedA = assertIs(first.availableIdentities.value[idA]) + val storedB = assertIs(first.availableIdentities.value[idB]) + + first.setActiveIdentity(activated(storedA)) + first.switchToIdentity(idB) + first.setActiveIdentity(activated(storedB)) + // Read through the view model's own GlobalPrefs, not this test's: DataStoreManager + // caches the global preferences once per process, bound to whichever directory + // was current when the first test in this JVM asked -- the same trap the nsec plan + // recorded for user preferences. The app has one directory; a test JVM has many. + val prefs = first.getGlobalPrefs() + withTimeout(5_000) { prefs.getDefaultWallet.first { it == idB } } + + // A cold boot: a fresh view model over the same directory, nothing desired, + // nothing forced, and the user has not asked for the list. + val booted = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + withTimeout(15_000) { booted.availableIdentities.first { it.size == 2 } } + val resolved = StartupChoice.resolve( + force = null, + desired = booted.desiredWalletId.value, + startImmediately = booted.startWalletImmediately.value, + identities = booted.availableIdentities.value, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + assertEquals(idB, resolved?.id, "the profile that was open is the one that opens") + + // The forget tail clears it; the boot after shows the list. + booted.forgetDefaultIdentity() + withTimeout(5_000) { prefs.getDefaultWallet.first { it == EmptyWalletId } } + assertNull( + StartupChoice.resolve( + force = null, + desired = null, + startImmediately = true, + identities = booted.availableIdentities.value, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + ) + } + @Test fun `switching from nothing, or from a bare key, stops nothing`() = runBlocking { val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) From 5654e4624b439b07603147bfe27e1d08879a8f69 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 00:50:37 +0200 Subject: [PATCH 47/71] feat(ui): the switcher, showing the nostr profile and what the device holds for it Phase 4 of docs/multiple-profiles.md. One pushed screen, and the startup selector brought up to match it. ProfilesRoute, reached from the profile tab's row -- "Switch profile", where it said "Change account" and went to the pending screen -- and drawn by ProfilesScreen: a top app bar with a back button, and WalletsSelector as its whole content with the open profile above the divider and the others below. Tapping another row is switchToIdentity and nothing else; the observer restarts the app as that identity through its lock gate, so the screen is never around to see the result. Tapping the open row does nothing. Three states, said so: loading while the list is read, which in practice is never seen; an unreadable key file, through ErrorState with a retry that re-lists; and the list. Empty cannot happen while something is signed in and the screen does not pretend it can. One column at every width. The row is offered to every kind: a read-only identity can leave for another profile the same way a signing one can. ProfilesViewModel joins the flows the app already holds -- the listing state, the identities, the active identity, the wallet metadata -- to the profile rows for the listed keys, through NostrRepository.observeProfilesOf, one map that moves when any of them does. Nothing in it writes. What a row shows changes. WalletsSelector showed "Default name" over a random emoji for every row, because nothing in this app writes the wallet metadata's name, and two profiles side by side both called Default name and told apart by a bech32 string is not a switcher. The row now takes the nostr profile -- humanReadableNameOrPubkey over ProfileAvatar, the widgets the profile tab draws itself with -- with the npub on the second line, and falls back to the npub over the metadata's emoji for a key the device has no kind 0 for yet: one just created, or a read-only one that was never found. And it says what the device holds for the profile before the tap: "Wallet" for a seed attached, beside the "Read only" that was already there, and nothing for the plain case of a bare key. It is the difference between a profile that can be signed out of and one whose sign-out is a wallet question, and between two profiles with the same name. The startup screen draws the same widget and gets the same map through rememberProfilesOf, so both lists look the same. The selector's dead globalPrefs parameter goes with the rewrite. Tests: ProfilesScreenJvmTest, on the unmerged tree, with a wallet-attached A open, a bare B and a read-only C -- A by its nostr name with its npub and "Wallet" under it, B and C by their npub with only C labelled, no "Default name" anywhere, the open one drawn above the others; tapping A calls nothing and tapping B calls the switch once with B's id; an unreadable key file is an error whose retry is counted; the bar has a back button. ReadOnlyEntrancesJvmTest asserts "Switch profile" for both kinds. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@25464595f4a1155f4f2637cb5a71efe17e3cde1a --- .../composeResources/values/strings.xml | 5 +- .../mantra/compose/repository/ProfilesOf.kt | 24 +++ .../ui/composable/ActiveProfileScreen.kt | 13 +- .../compose/ui/composable/ProfilesScreen.kt | 92 ++++++++++ .../SovereignWalletStartupScreen.kt | 10 +- .../ui/composable/navigation/MantraNavHost.kt | 30 ++++ .../navigation/routes/ProfilesRoute.kt | 11 ++ .../widgets/wallet/RememberProfilesOf.kt | 23 +++ .../widgets/wallet/WalletsSelector.kt | 69 +++++-- .../ui/view/model/ProfilesViewModel.kt | 95 ++++++++++ .../compose/ui/view/state/ProfilesUIState.kt | 28 +++ .../ui/composable/ProfilesScreenJvmTest.kt | 168 ++++++++++++++++++ .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 3 + 13 files changed, 547 insertions(+), 24 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index afcbc220..2eeb8f7b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -42,7 +42,6 @@ BIP39 seed with the standard BIP84 derivation path. The profile\'s nostr key comes off the same seed, so these 12 words restore both. Block user Cancel - Change account Chapter detail Chapter name Chapter translation @@ -478,4 +477,8 @@ Preparing profiles… Starting wallet… Unlock to continue + + Loading profiles… + Switch profile + Wallet diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt new file mode 100644 index 00000000..990ac668 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ProfilesOf.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.repository + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.Profile + +/** + * The nostr profile of each of [publicKeys], as one map that moves when any of them + * does. For the two lists of the device's profiles -- the switcher and the startup + * selector -- which show a name and a picture where the device has a kind 0 and the + * npub where it does not. + */ +fun NostrRepository.observeProfilesOf(publicKeys: Collection): Flow> { + val keys = publicKeys.distinct() + if (keys.isEmpty()) return flowOf(emptyMap()) + val flows = keys.map { publicKey -> + flow { emitAll(observeProfileWithPublicKey(publicKey)) } + } + return combine(flows) { profiles -> keys.zip(profiles.toList()).toMap() } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index f467485c..4b67bc7d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -54,7 +54,6 @@ import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource -import mantra.composeapp.generated.resources.change_account import mantra.composeapp.generated.resources.edit_profile import mantra.composeapp.generated.resources.key_package_management import mantra.composeapp.generated.resources.network_relays @@ -62,6 +61,8 @@ import mantra.composeapp.generated.resources.profile import mantra.composeapp.generated.resources.key_recovery import mantra.composeapp.generated.resources.share_profile import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.switch_profile +import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute import mantra.composeapp.generated.resources.sign_out_of_this_profile_question import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again import press.mantra.compose.identity.Identity @@ -302,18 +303,18 @@ fun ActiveProfileScreen( } + // For every kind: a read-only identity can leave for another + // profile the same way a signing one can. item { TextButton( onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Change account") - ) + onNavigateToRoute.invoke(ProfilesRoute) } ) { Icon( Icons.Default.ImportExport, - contentDescription = "Change profile" + contentDescription = Decorative ) Spacer( @@ -321,7 +322,7 @@ fun ActiveProfileScreen( ) Text( - stringResource(Res.string.change_account) + stringResource(Res.string.switch_profile) ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt new file mode 100644 index 00000000..cd0805cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt @@ -0,0 +1,92 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.loading_profiles +import mantra.composeapp.generated.resources.profiles +import fr.acinq.phoenix.data.WalletId +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.ProfilesViewModel +import press.mantra.compose.ui.view.state.ProfilesUIState + +/** + * The profiles on this device -- the one that is open above a divider, the others + * below -- and the way from one to another. Phase 4 of docs/multiple-profiles.md. + * + * Tapping another row is [onSwitch] and nothing else: the navigation observer restarts + * the app as that identity, through its lock gate, so this screen is never around to + * see the result. Tapping the current row does nothing. A pushed screen behind one + * deliberate tap from the profile tab, and never a global control, because a switch + * clears the back stack from wherever the user is. + * + * One column at every width: a list of a handful of rows has no detail to pair with. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilesScreen( + viewModel: ProfilesViewModel, + onNavigateBack: () -> Unit, + onSwitch: (WalletId) -> Unit, + /** Re-lists. An unreadable key file is exactly the failure a retry can answer. */ + onRetry: () -> Unit, + /** The two ways to add a profile, drawn under the list; Phase 5 fills the slot. */ + addProfile: (@Composable () -> Unit)? = null, +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.profiles)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .readableContent(), + ) { + ScreenStateTransition(uiState) { state -> + when (state) { + is ProfilesUIState.Loading -> LoadingDataIndicator( + text = stringResource(Res.string.loading_profiles), + ) + is ProfilesUIState.Error -> ErrorState(onRetry = onRetry) + is ProfilesUIState.Loaded -> WalletsSelector( + modifier = Modifier.fillMaxSize().padding(horizontal = MaterialTheme.spacing.screenMargin), + wallets = state.identities, + walletsMetadata = state.walletsMetadata, + activeWalletId = state.activeId, + canEdit = false, + onWalletClick = { onSwitch(it.id) }, + profiles = state.profiles, + bottomContent = addProfile, + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index a0933d79..0d80ba68 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.first import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.wallet.rememberProfilesOf import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @@ -46,6 +48,8 @@ import press.mantra.compose.ui.view.state.StartupChoice @Composable fun SovereignWalletStartupScreen( sovereignWalletViewModel: press.mantra.compose.ui.view.model.SovereignWalletViewModel, + /** For the selector's rows: the nostr profile of each listed key, where the device has one. */ + nostrRepository: NostrRepository, onNavigateToWalletLandingPage: () -> Unit, onSuccessfulStartup: () -> Unit, forceWalletId: WalletId?, @@ -139,11 +143,15 @@ fun SovereignWalletStartupScreen( } when (val stored = loadingIdentity) { null -> { + val profiles by rememberProfilesOf( + publicKeys = availableIdentities.values.map { it.nostrPublicKey }, + nostrRepository = nostrRepository, + ) press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector( wallets = availableIdentities, - globalPrefs = globalPrefs, walletsMetadata = availableWalletMetadata, activeWalletId = null, + profiles = profiles, onWalletClick = { sovereignWalletViewModel.switchToIdentity( it.id diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 868e5c46..6d05f453 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -85,7 +85,11 @@ import press.mantra.compose.ui.composable.navigation.routes.LandingRoute import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute +import press.mantra.compose.ui.composable.ProfilesScreen +import press.mantra.compose.ui.view.model.ProfilesViewModel +import press.mantra.compose.ui.view.model.getAvailableWalletsMeta import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute @@ -455,6 +459,7 @@ fun MantraNavHost( SovereignWalletStartupScreen( sovereignWalletViewModel = sovereignWalletViewModel, + nostrRepository = databaseNostrRepository, // No seed on this device, so there is no wallet to start: the user has to make or // restore one, and that lives behind the landing screen. This used to park them on // a loading screen with nothing left to load. @@ -889,6 +894,31 @@ fun MantraNavHost( } ) } + composable { + val profilesViewModel: ProfilesViewModel = viewModel( + factory = ProfilesViewModel.factory( + listWalletState = sovereignWalletViewModel.listWalletState, + availableIdentities = sovereignWalletViewModel.availableIdentities, + activeIdentity = sovereignWalletViewModel.activeIdentity, + walletsMetadata = getAvailableWalletsMeta(phoenixGlobal), + nostrRepository = databaseNostrRepository, + ), + ) + ProfilesScreen( + viewModel = profilesViewModel, + onNavigateBack = { + navController.popBackStack() + }, + // The switch is the whole of it: the observer above sends the null + // identity to startup, and startup opens the one named. + onSwitch = { walletId -> + sovereignWalletViewModel.switchToIdentity(walletId) + }, + onRetry = { + sovereignWalletViewModel.listIdentities {} + }, + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt new file mode 100644 index 00000000..eacf0902 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilesRoute.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The profiles on this device: the one that is open, the others, and the two ways to + * add one. Pushed from the profile tab; no arguments, since everything it shows is the + * device's rather than a route's. See docs/multiple-profiles.md, Phase 4. + */ +@Serializable +object ProfilesRoute : Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt new file mode 100644 index 00000000..e0a10aee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/RememberProfilesOf.kt @@ -0,0 +1,23 @@ +package press.mantra.compose.ui.composable.widgets.wallet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.Profile +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.repository.observeProfilesOf + +/** + * The nostr profiles of [publicKeys], for a screen that has no view model to hold them + * -- the startup screen, whose own state machine is the wallet's. The switcher gets the + * same map from `ProfilesViewModel`; both draw `WalletsSelector`, and both lists should + * look the same. + */ +@Composable +fun rememberProfilesOf(publicKeys: Collection, nostrRepository: NostrRepository): State> { + val keys = publicKeys.toSet() + return produceState>(initialValue = emptyMap(), keys, nostrRepository) { + nostrRepository.observeProfilesOf(keys).collect { value = it } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index b273d2d1..d0fabe47 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -44,26 +44,37 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import press.mantra.compose.ui.composable.widgets.buttons.Clickable +import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.WalletId -import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import press.mantra.compose.extensions.hexToNpubHrp import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.read_only +import mantra.composeapp.generated.resources.wallet import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.Profile import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.spacing +/** + * The device's profiles as a list: the open one above a divider, the others below, + * each with its nostr name and picture where the device holds a kind 0 for it and its + * npub where it does not. + * + * @param profiles the nostr profile of each listed key, by public key; a key with no + * entry, or a null one, is shown by its npub over the metadata's emoji. + */ @Composable fun WalletsSelector( modifier: Modifier = Modifier, - globalPrefs: GlobalPrefs, wallets: Map, walletsMetadata: Map, activeWalletId: WalletId?, canEdit: Boolean, onWalletClick: (StoredIdentity) -> Unit, + profiles: Map = emptyMap(), verticalArrangement: Arrangement.Vertical = Arrangement.Top, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, topContent: @Composable (() -> Unit)? = null, @@ -81,7 +92,7 @@ fun WalletsSelector( item { AvailableWalletView( identity = currentWallet, - globalPrefs = globalPrefs, + profile = profiles[currentWallet.nostrPublicKey], metadata = walletsMetadata.getByWalletIdOrDefault(currentWallet.id), isCurrent = true, canEdit = canEdit, @@ -95,7 +106,7 @@ fun WalletsSelector( items(items = otherWalletsList) { (walletId, identity) -> AvailableWalletView( identity = identity, - globalPrefs = globalPrefs, + profile = profiles[identity.nostrPublicKey], metadata = walletsMetadata.getByWalletIdOrDefault(walletId), isCurrent = false, canEdit = canEdit, @@ -110,15 +121,23 @@ fun WalletsSelector( } /** - * The second line is the npub for both kinds. It used to be the node id, which an - * identity made from a bare key does not have -- and the npub is the identifier the rest - * of the app shows, and the one a user might actually recognise. + * One profile. The name and picture are the nostr profile's -- the widgets the profile + * tab draws itself with -- and the npub is the second line, the identifier the rest of + * the app shows and the one a user might actually recognise. A key the device has no + * kind 0 for yet, one just created or a read-only one never found, is the npub over the + * metadata's emoji. It used to be "Default name" over a random emoji for every row, + * because nothing in this app writes the wallet metadata's name. + * + * What the device holds for the profile is said before the tap -- *wallet* for a seed + * attached, *read only* for a bare public key, nothing for the plain case of a bare + * key -- because it is the difference between a profile that can be signed out of and + * one whose sign-out is a wallet question, and between two profiles with the same name. */ @Composable private fun AvailableWalletView( modifier: Modifier = Modifier, - globalPrefs: GlobalPrefs, identity: StoredIdentity, + profile: Profile?, metadata: UserWalletMetadata, isCurrent: Boolean, canEdit: Boolean, @@ -156,18 +175,33 @@ private fun AvailableWalletView( .weight(1f), verticalAlignment = Alignment.CenterVertically ) { - WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(MaterialTheme.spacing.space50)) + if (profile != null) { + ProfileAvatar(size = AvatarSize, publicKey = identity.nostrPublicKey, profile = profile) + } else { + WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(MaterialTheme.spacing.space50)) + } Spacer(Modifier.width(MaterialTheme.spacing.space150)) Column { - Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.height(MaterialTheme.spacing.space25)) - Text(text = identity.nostrPublicKey.hexToNpubHrp(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) - if (identity is StoredIdentity.NostrPublic) { - // Said before the tap, not after: two identities can share an avatar - // and a name, and only one of them will let the user send a message. + val npub = identity.nostrPublicKey.hexToNpubHrp() + Text( + text = profile?.humanReadableNameOrPubkey() ?: npub, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + if (profile != null) { + Spacer(Modifier.height(MaterialTheme.spacing.space25)) + Text(text = npub, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) + } + val held = when (identity) { + is StoredIdentity.Mnemonic -> Res.string.wallet + is StoredIdentity.NostrPublic -> Res.string.read_only + is StoredIdentity.NostrSecret -> null + } + if (held != null) { Spacer(Modifier.height(MaterialTheme.spacing.space25)) Text( - text = stringResource(Res.string.read_only), + text = stringResource(held), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -182,3 +216,6 @@ private fun AvailableWalletView( } } } + +/** A dimension, not a spacing: the picture is the same size the emoji avatar renders at. */ +private val AvatarSize = 44.dp diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt new file mode 100644 index 00000000..b84e62e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilesViewModel.kt @@ -0,0 +1,95 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import fr.acinq.phoenix.data.ListWalletState +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.repository.observeProfilesOf +import press.mantra.compose.ui.view.state.ProfilesUIState + +/** + * What the switcher shows: the device's profiles, which one is open, and each one's + * nostr profile where the device has it. + * + * Everything comes from flows the app already holds -- the listing, the active + * identity, the wallet metadata -- joined to the profile rows for the listed keys. The + * three states follow `ListWalletState`: `Init` is loading, `Error` is an unreadable + * key file, and `Success` is the list. Nothing here writes; the switch itself is the + * screen's callback into `SovereignWalletViewModel.switchToIdentity`. + */ +class ProfilesViewModel( + listWalletState: StateFlow, + availableIdentities: StateFlow>, + activeIdentity: StateFlow, + walletsMetadata: Flow>, + nostrRepository: NostrRepository, +) : ViewModel() { + + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = combine( + listWalletState, + availableIdentities, + activeIdentity, + walletsMetadata, + ) { listState, identities, active, metadata -> + Inputs(listState, identities, active?.id, metadata) + }.flatMapLatest { inputs -> + when (inputs.listState) { + is ListWalletState.Init -> flowOf(ProfilesUIState.Loading) + is ListWalletState.Error -> flowOf(ProfilesUIState.Error) + is ListWalletState.Success -> nostrRepository + .observeProfilesOf(inputs.identities.values.map { it.nostrPublicKey }) + .map { profiles -> + ProfilesUIState.Loaded( + identities = inputs.identities, + activeId = inputs.activeId, + walletsMetadata = inputs.metadata, + profiles = profiles, + ) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfilesUIState.Loading) + + private data class Inputs( + val listState: ListWalletState, + val identities: Map, + val activeId: WalletId?, + val metadata: Map, + ) + + companion object { + fun factory( + listWalletState: StateFlow, + availableIdentities: StateFlow>, + activeIdentity: StateFlow, + walletsMetadata: Flow>, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProfilesViewModel( + listWalletState = listWalletState, + availableIdentities = availableIdentities, + activeIdentity = activeIdentity, + walletsMetadata = walletsMetadata, + nostrRepository = nostrRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt new file mode 100644 index 00000000..f680b2f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilesUIState.kt @@ -0,0 +1,28 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import press.mantra.compose.database.model.Profile +import press.mantra.compose.identity.StoredIdentity + +/** + * The switcher's states. Three, not four: an empty list cannot happen while something + * is signed in, and the screen does not pretend it can. + */ +sealed interface ProfilesUIState { + /** The list is being read -- in practice never seen, since startup read it, but the state exists and says what it waits for. */ + data object Loading : ProfilesUIState + + /** One of the two key files could not be read. A retry re-lists. */ + data object Error : ProfilesUIState + + data class Loaded( + val identities: Map, + /** The one that is open, drawn above the divider. */ + val activeId: WalletId?, + val walletsMetadata: Map, + /** The nostr profile of each listed key, where the device has one; the row falls back to the npub. */ + val profiles: Map, + ) : ProfilesUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt new file mode 100644 index 00000000..c855cc83 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt @@ -0,0 +1,168 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.data.ListWalletState +import fr.acinq.phoenix.data.UserWallet +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import fr.acinq.phoenix.utils.preferences.UserWalletMetadata +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import okio.Path.Companion.toPath +import press.mantra.compose.database.model.Profile +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.StoredIdentity +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.model.ProfilesViewModel +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The switcher, drawn -- Phase 4 of docs/multiple-profiles.md. + * + * Three profiles: A, with a wallet attached and open; B, a bare key; C, read only. What + * is pinned is what each row says before the tap -- the nostr name where the device has + * one and the npub where it does not, *wallet* and *read only* where they apply and + * nothing for the plain case -- that the open one sits above the others, and that + * tapping another row is exactly one call with its id while tapping the open one is + * none. On the unmerged tree throughout, as `ReadOnlyEntrancesJvmTest` explains. + */ +@OptIn(ExperimentalTestApi::class) +class ProfilesScreenJvmTest { + + private val keyA = PrivateKey(ByteVector32("0a".repeat(32))) + private val keyB = PrivateKey(ByteVector32("0b".repeat(32))) + private val keyC = PrivateKey(ByteVector32("0c".repeat(32))) + + private val walletId = WalletId("aa".repeat(20)) + private val a = StoredIdentity.Mnemonic(UserWallet(walletId, nodeId = "02" + "cd".repeat(32), words = List(12) { "abandon" }), keyA) + private val b = StoredIdentity.nostrSecret(keyB) + private val c = StoredIdentity.nostrPublic(keyC.nostrPublicKeyHex()) + private val identities = linkedMapOf(a.id to a, b.id to b, c.id to c) + + private fun prefsFile(name: String) = + Files.createTempFile(name, ".preferences_pb").also { it.toFile().delete() }.toString().toPath() + + private val open = Identity.signing( + id = a.id, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyA, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("user") }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("internal") }), + business = null, + ) + + /** A has a kind 0; B and C do not. */ + private val profiles = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = flowOf( + if (publicKey == a.nostrPublicKey) Profile(publicKey = publicKey, displayName = "Alice", nostrEventId = "e".repeat(64)) else null + ) + } + + private val metadata = identities.keys.associateWith { id -> + UserWalletMetadata(walletId = id, name = null, avatar = "🦊", createdAt = null, isHidden = false) + } + + private val switched = mutableListOf() + + private fun viewModel(listState: ListWalletState = ListWalletState.Success) = ProfilesViewModel( + listWalletState = MutableStateFlow(listState), + availableIdentities = MutableStateFlow(identities), + activeIdentity = MutableStateFlow(open), + walletsMetadata = flowOf(metadata), + nostrRepository = profiles, + ) + + @Composable + private fun Screen(viewModel: ProfilesViewModel, onRetry: () -> Unit = {}) { + MantraTheme { + ProvideSnackbarHost { + ProfilesScreen( + viewModel = viewModel, + onNavigateBack = {}, + onSwitch = { switched += it }, + onRetry = onRetry, + ) + } + } + } + + private fun ComposeUiTest.awaitLoaded() = waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText("Alice", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + + @Test + fun `each row says what the device holds, and the open one sits above the others`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + // A by its nostr name, with its npub under it and a wallet attached. + onNodeWithText("Alice", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText(a.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Wallet", useUnmergedTree = true).assertIsDisplayed() + // B and C by their npub, since the device has no kind 0 for either; only C carries a label. + onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed() + onNodeWithText(c.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Read only", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Default name", useUnmergedTree = true).assertDoesNotExist() + + val openTop = onNodeWithText("Alice", useUnmergedTree = true).getBoundsInRoot().top + val otherTop = onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).getBoundsInRoot().top + assertTrue(openTop < otherTop, "the open profile is drawn above the others") + } + + @Test + fun `tapping another profile switches to it once, and tapping the open one does nothing`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + onNodeWithText("Alice", useUnmergedTree = true).performClick() + assertEquals(emptyList(), switched) + + onNodeWithText(b.nostrPublicKey.hexToNpubHrp(), useUnmergedTree = true).performClick() + assertEquals(listOf(b.id), switched) + } + + @Test + fun `an unreadable key file is an error with a retry`() = runDesktopComposeUiTest(400, 900) { + var retried = 0 + setContent { Screen(viewModel(ListWalletState.Error.Serialization), onRetry = { retried++ }) } + + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText("Try again", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + onNodeWithText("Try again", useUnmergedTree = true).performClick() + assertEquals(1, retried) + } + + @Test + fun `the list is a pushed screen, with a way back`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + onNodeWithText("Profiles", useUnmergedTree = true).assertIsDisplayed() + onNodeWithContentDescription("Back", useUnmergedTree = true).assertIsDisplayed() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt index 2dfc62ac..eb85a62f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -101,6 +101,7 @@ class ReadOnlyEntrancesJvmTest { onNodeWithText("Key recovery", useUnmergedTree = true).assertIsDisplayed() onNodeWithText("Key package management", useUnmergedTree = true).assertIsDisplayed() onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed() } @Test @@ -110,6 +111,8 @@ class ReadOnlyEntrancesJvmTest { onNodeWithText("Key recovery", useUnmergedTree = true).assertDoesNotExist() onNodeWithText("Key package management", useUnmergedTree = true).assertDoesNotExist() onNodeWithText("Sign out", useUnmergedTree = true).assertIsDisplayed() + // A read-only identity can leave for another profile the same way a signing one can. + onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed() } // --- Profile: sign out is real for a read-only identity, and confirms first --- From ccb9942a187c0c21516260ee843681ef4adfd5de Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 01:24:14 +0200 Subject: [PATCH 48/71] feat(identity): adding a profile from inside, as a key and not a second wallet Phase 5 of docs/multiple-profiles.md. The two entrances, and the two things that were only safe with one profile. Under the list, on the switcher and on the startup selector alike, two rows: "sign in with a key", to the sign-in screen, and "create a new profile", to the create screen. Both push a screen that has a back button. Landing is not one of them -- it has no back button and clears the stack on its way out, because it is the first-run screen, and pushing it from inside would make it a pushed screen on some days and a root on others. A device with one profile can now be given a second, which it could not: Landing showed only when the device held nothing. A profile created from inside is a bare key. CreateProfileScreen generated twelve words behind its form -- a seed, a node, a wallet, and at the NIP-06 path the key that becomes the profile -- which is right for the first profile on a device, the one the wallet will belong to, and wrong for the second: a user who wants another profile has not asked for another Lightning node, another set of channels, or a second phrase with funds behind it. The screen now takes a NewProfileWriter and does not know which it was given: seedProfileWriter from Landing, bareKeyProfileWriter from the switcher, thirty-two random bytes written the way a pasted nsec is. CreateProfileRoute carries the choice. The view model writes the secret first, then the six bootstrap events for the key it derives, then hands the id to the tail -- so the events are only ever queued for a key the device holds, and they are in the database before the tail activates the identity, an ordering the sign-in screen documents as load-bearing and the create flow used to get away with by luck. The tail gains the popUpTo(0) the sign-in tail has. A second wallet is not offered from inside; a pasted recovery phrase still brings its own. "End this" goes. It wiped the database -- every profile's rooms, MLS state, key packages and queues -- from the screen whose purpose is to add a profile; it was only ever a development exit, and with several profiles on a device it was a way to lose the others. wipeDatabase leaves the view model and stays on the repository for the tests that use it. A key already on the device is refused with the id it is listed under. WriteNostrCredentialResult.AlreadyExists and WritingSeedState.Error. SeedAlreadyExists carry it -- the wallet's id where a seed derives the key -- and the sign-in screen's error state gains one action, "switch to it", which is the sign-in tail with that id. From Landing "already on this device" was the whole message; from inside it is a sentence with an obvious next step, and a duplicate paste becomes the fastest switch in the app. Found on the way, and fixed in the jvm actuals: DataStoreManager caches one GlobalPrefs per process behind an unsynchronised check-then-set, and DataStore refuses a second instance over the same file. Two first calls at once -- the listing on IO and a sign-in's write, or the startup screen on Main -- could both see the empty cache and both build one, and the loser threw "multiple DataStores active for the same file" at first use. The test for this phase hit it one run in three. JvmGlobalPrefs is now the one way the jvm target reaches the prefs, under a lock; Android goes through the Application's single instance and never raced; the library's own callers come at node start, when the cache is long populated. Tests: AddProfileFromInsideJvmTest, through the real view models on an in-memory database -- A open, B's nsec committed and switched to, and back, both accounts intact with their own kind 0; A's key pasted again refused with A's id; a profile made through the bare-key writer lists as a bare key with the kind 0 first of its six bootstrap rows, none signed yet, and no node ran. SignInToProfileViewModelJvmTest and IdentityWriterJvmTest assert the ids the three refusals carry. ProfilesScreenJvmTest: the two rows call their callbacks and switch nothing, under the caption that the open profile stays. CreateProfileScreenJvmTest: the state that carried "end this" is composed and the words are not there. Replayed onto Mantra by docs/curated-to-mantra.md: CreateProfileViewModel.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the seed derivation that import served; the file is identical on both sides afterwards (docs/curated-to-mantra-profiles.md, the third decision). Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@2f4202308155085707f065129e2fdfeecd08ea9d --- .../composeResources/values/strings.xml | 6 +- .../mantra/compose/identity/IdentityWriter.kt | 41 ++-- .../compose/identity/NewProfileWriter.kt | 24 +++ .../ui/composable/CreateProfileScreen.kt | 54 ++--- .../compose/ui/composable/ProfilesScreen.kt | 10 +- .../compose/ui/composable/SignInScreen.kt | 25 ++- .../SovereignWalletStartupScreen.kt | 11 +- .../ui/composable/navigation/MantraNavHost.kt | 64 +++--- .../navigation/routes/CreateProfileRoute.kt | 10 +- .../widgets/wallet/AddProfileRows.kt | 75 +++++++ .../ui/view/model/CreateProfileViewModel.kt | 95 +++------ .../ui/view/model/NewProfileWriters.kt | 40 ++++ .../ui/view/model/SignInToProfileViewModel.kt | 20 +- .../ui/view/model/SovereignWalletViewModel.kt | 3 +- .../ui/view/state/SignInToProfileUIState.kt | 8 +- .../compose/extensions/JvmGlobalPrefs.kt | 26 +++ .../mantra/compose/extensions/Phoenix.jvm.kt | 6 +- .../ui/view/model/NavigationViewModel.jvm.kt | 18 +- .../identity/AddProfileFromInsideJvmTest.kt | 186 ++++++++++++++++++ .../compose/identity/IdentitySwitchJvmTest.kt | 15 +- .../compose/identity/IdentityWriterJvmTest.kt | 7 +- .../composable/CreateProfileScreenJvmTest.kt | 49 +++++ .../ui/composable/ProfilesScreenJvmTest.kt | 17 ++ .../model/SignInToProfileViewModelJvmTest.kt | 15 +- 24 files changed, 624 insertions(+), 201 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2eeb8f7b..b74dcb34 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -85,7 +85,6 @@ eg. st eg. To Kill a Mocking Bird Emergency kit - end this ENDED Encrypt and back your recovery information up to your Google Drive or iCloud. Enter the name you want to use for your group @@ -481,4 +480,9 @@ Loading profiles… Switch profile Wallet + + Create a new profile + Sign in with a key + Switch to it + The profile you are in stays on this device. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt index c784828c..62ea1fd5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -55,12 +55,11 @@ import press.mantra.compose.ui.view.model.WritingSeedState */ object IdentityWriter { - /** What the seed store says, or null if it could not be read. */ - private fun seedPublicKeys(phoenixGlobal: PhoenixGlobal): Set? = + /** Each seed's nostr public key and the wallet id it is filed under, or null if the store could not be read. */ + private fun seedWalletIds(phoenixGlobal: PhoenixGlobal): Map? = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?.values - ?.map { StoredIdentity.nostrPublicKeyOf(it.words) } - ?.toSet() + ?.associate { StoredIdentity.nostrPublicKeyOf(it.words) to it.walletId } /** * Creates the preference files a new id needs and records the choices made before @@ -137,10 +136,12 @@ object IdentityWriter { existingSeeds.containsKey(newWalletId) -> { // The same seed, and so the same nostr key with a wallet already attached. // Two different seeds cannot derive one nostr key, so this is the only - // way a profile with a wallet attached is offered its phrase again. + // way a profile with a wallet attached is offered its phrase again. The + // id goes with the refusal: from inside the app, "already here" has an + // obvious next step, which is to switch to it. log.i("attempting to import a seed that already exists, aborting...") onWritingSeedError.invoke( - WritingSeedState.Error.SeedAlreadyExists + WritingSeedState.Error.SeedAlreadyExists(newWalletId) ) return@launch } @@ -205,8 +206,10 @@ object IdentityWriter { /** * An identity with this nostr public key is already on the device and holds at least * what was pasted: a wallet or a secret, for either input; a public key, for an npub. + * [id] is the one it is listed under -- the wallet's where a seed derives the key -- + * so that the refusal can offer to switch to it. */ - data object AlreadyExists : WriteNostrCredentialResult() + data class AlreadyExists(val id: WalletId) : WriteNostrCredentialResult() /** One of the two stores exists and could not be read; nothing was written. */ data object CannotLoadKeys : WriteNostrCredentialResult() @@ -233,15 +236,19 @@ object IdentityWriter { log.d("writing nostr key to disk...") val stored = StoredIdentity.nostrSecret(privateKey) - val seeds = seedPublicKeys(phoenixGlobal) + val seeds = seedWalletIds(phoenixGlobal) val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") return WriteNostrCredentialResult.CannotLoadKeys } - if (stored.nostrPublicKey in seeds || existing[stored.nostrPublicKey] is NostrCredential.Secret) { + seeds[stored.nostrPublicKey]?.let { walletId -> + log.i("attempting to import the nostr key of a wallet that is already here, aborting...") + return WriteNostrCredentialResult.AlreadyExists(walletId) + } + if (existing[stored.nostrPublicKey] is NostrCredential.Secret) { log.i("attempting to import a nostr key that is already here, aborting...") - return WriteNostrCredentialResult.AlreadyExists + return WriteNostrCredentialResult.AlreadyExists(stored.id) } val upgrading = existing[stored.nostrPublicKey] is NostrCredential.Public @@ -277,15 +284,19 @@ object IdentityWriter { log.d("writing nostr public key to disk...") val stored = StoredIdentity.nostrPublic(nostrPublicKey) - val seeds = seedPublicKeys(phoenixGlobal) + val seeds = seedWalletIds(phoenixGlobal) val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") return WriteNostrCredentialResult.CannotLoadKeys } - if (stored.nostrPublicKey in seeds || existing.containsKey(stored.nostrPublicKey)) { + seeds[stored.nostrPublicKey]?.let { walletId -> + log.i("attempting to add the public key of a wallet that is already here, aborting...") + return WriteNostrCredentialResult.AlreadyExists(walletId) + } + if (existing.containsKey(stored.nostrPublicKey)) { log.i("attempting to add a public key the device already holds something for, aborting...") - return WriteNostrCredentialResult.AlreadyExists + return WriteNostrCredentialResult.AlreadyExists(stored.id) } NostrCredentialManager.writeToDisk( @@ -327,13 +338,13 @@ object IdentityWriter { id: WalletId, nostrPublicKey: HexKey, ): ForgetNostrCredentialResult { - val seeds = seedPublicKeys(phoenixGlobal) + val seeds = seedWalletIds(phoenixGlobal) val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") return ForgetNostrCredentialResult.CannotLoadKeys } - if (nostrPublicKey in seeds) { + if (seeds.containsKey(nostrPublicKey)) { log.i("asked to forget a key a seed derives; the wallet has to go first") return ForgetNostrCredentialResult.WalletAttached } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt new file mode 100644 index 00000000..fb8df175 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/NewProfileWriter.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.identity + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.WalletId + +/** What a [NewProfileWriter] made: the key the new profile signs as, and the id it is listed under. */ +data class NewProfile(val nostrPublicKey: HexKey, val id: WalletId) + +/** + * Makes the secret a new profile signs with and puts it on disk. + * + * Two live in the app, and the create screen does not know which it was given. One + * generates twelve words and writes a seed -- for Landing, where the first profile on a + * device is the one the wallet will belong to. The other generates a bare key and + * writes a credential -- for the switcher, where a user who wants another profile has + * not asked for another Lightning node, another set of channels, or a second phrase + * with funds behind it. Under docs/multiple-profiles.md the two differ by exactly one + * thing, whether a wallet is attached to the profile they make, which is what the model + * was for. + */ +fun interface NewProfileWriter { + /** Generates and writes the secret. A failure is a write that did not happen; nothing is half-made. */ + suspend fun write(): Result +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt index 28cc9ee3..13991e6c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme @@ -34,6 +33,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.repository.MarmotRepository +import press.mantra.compose.identity.NewProfileWriter +import fr.acinq.phoenix.data.WalletId import press.mantra.compose.ui.theme.LocalExtendedColors import press.mantra.compose.ui.view.model.CreateProfileViewModel import press.mantra.compose.ui.view.state.CreateProfileUIState @@ -43,7 +44,6 @@ import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.after_this_there_is_no_turning_back import mantra.composeapp.generated.resources.bio import mantra.composeapp.generated.resources.create_profile -import mantra.composeapp.generated.resources.end_this import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2 import mantra.composeapp.generated.resources.introduce_yourself import mantra.composeapp.generated.resources.it_is_cryptographical_secure_and @@ -70,11 +70,13 @@ import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun CreateProfileScreen( initialCreateProfileUIState: CreateProfileUIState = CreateProfileUIState.Declaration, - onNavigateToEndThis: () -> Unit, onNavigateBack: () -> Unit, nostrRepository: press.mantra.compose.repository.NostrRepository, marmotRepository: MarmotRepository, - writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit + /** Which secret the profile is made of -- a seed from Landing, a bare key from the switcher. The screen does not know which. */ + newProfile: NewProfileWriter, + /** The nav host's tail: re-list, select, go to startup. */ + onProfileCreated: (WalletId) -> Unit, ) { val createProfileViewModel: CreateProfileViewModel = viewModel ( factory = CreateProfileViewModel.factory( @@ -309,7 +311,7 @@ fun CreateProfileScreen( } else { Button( onClick = { - createProfileViewModel.createAccount(writeSeed) + createProfileViewModel.createAccount(newProfile, onProfileCreated) } ) { Text( @@ -333,11 +335,16 @@ fun CreateProfileScreen( ) } is CreateProfileUIState.ProfileReady -> { - // Both pills come from the extended families rather than from a - // literal paired with Color.White/Color.DarkGray by eye. The old - // pairings read 4.57:1 and 2.90:1 against their containers, and - // RedPill carried alpha 0.749, so composited over the surface the - // first was really 3.50:1. Both are below the 4.5:1 floor. + // The pill comes from the extended family rather than from a + // literal paired with Color.White by eye: the old pairing read + // 4.57:1 against its container, and with alpha 0.749 composited + // over the surface was really 3.50:1, below the 4.5:1 floor. + // + // The blue pill that stood beside it -- "end this" -- is gone. It + // wiped the database: every profile's rooms, MLS state, key + // packages and queues, from a screen whose purpose is to add a + // profile. It was only ever a development exit, and with several + // profiles on a device it was a way to lose the others. val extendedColors = LocalExtendedColors.current Spacer( @@ -359,34 +366,13 @@ fun CreateProfileScreen( contentColor = extendedColors.redPill.onColor ), onClick = { - createProfileViewModel.createAccount(writeSeed) + createProfileViewModel.createAccount(newProfile, onProfileCreated) } ) { Text( text = stringResource(Res.string.see_how_deep_the_rabbit_hole_goes) ) } - - HorizontalDivider( - modifier = Modifier.padding(MaterialTheme.spacing.space250) - ) - - Button( - colors = ButtonDefaults.buttonColors( - containerColor = extendedColors.bluePill.color, - contentColor = extendedColors.bluePill.onColor - ), - onClick = { - // TODO: Delete everything and close the app - createProfileViewModel.wipeDatabase { - onNavigateToEndThis.invoke() - } - } - ) { - Text( - text = stringResource(Res.string.end_this) - ) - } Spacer( modifier = Modifier.weight(2f) ) @@ -441,9 +427,9 @@ fun CreateAccountScreenPreview() { // about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider." // ) // ), - onNavigateToEndThis = {}, onNavigateBack = {}, - writeSeed = { _, _, _ -> }, + newProfile = NewProfileWriter { Result.failure(IllegalStateException("a preview")) }, + onProfileCreated = {}, nostrRepository = press.mantra.compose.repository.NostrRepository.NO_OP_NOSTR_REPOSITORY, marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt index cd0805cc..9efbac71 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilesScreen.kt @@ -23,6 +23,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows import press.mantra.compose.ui.composable.widgets.wallet.WalletsSelector import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing @@ -49,8 +50,9 @@ fun ProfilesScreen( onSwitch: (WalletId) -> Unit, /** Re-lists. An unreadable key file is exactly the failure a retry can answer. */ onRetry: () -> Unit, - /** The two ways to add a profile, drawn under the list; Phase 5 fills the slot. */ - addProfile: (@Composable () -> Unit)? = null, + /** The two ways to add a profile, under the list. Both push a screen with a back button. */ + onSignIn: () -> Unit, + onCreateProfile: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() @@ -83,7 +85,9 @@ fun ProfilesScreen( canEdit = false, onWalletClick = { onSwitch(it.id) }, profiles = state.profiles, - bottomContent = addProfile, + bottomContent = { + AddProfileRows(onSignIn = onSignIn, onCreateProfile = onCreateProfile, caption = true) + }, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index 050702ba..a809c665 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -48,6 +48,7 @@ import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_ import mantra.composeapp.generated.resources.recovery_phrase_nsec_or_npub import mantra.composeapp.generated.resources.sign_in import mantra.composeapp.generated.resources.signed_in +import mantra.composeapp.generated.resources.switch_to_it import mantra.composeapp.generated.resources.signing_you_in import mantra.composeapp.generated.resources.that_is_not_a_recovery_phrase_or_a_nostr_key import mantra.composeapp.generated.resources.that_nostr_secret_key_is_not_valid @@ -102,6 +103,11 @@ fun SignInToProfileScreen( onError: (WritingSeedState.Error) -> Unit, ) -> Unit, onNavigateBack: () -> Unit, + /** + * The tail: re-list, select, go to startup. Also what "switch to it" does when the key + * pasted is already here -- the same tail with the id it is here under, which makes a + * duplicate paste the fastest switch in the app. + */ onSignedIn: (WalletId) -> Unit, ) { val viewModel: SignInToProfileViewModel = viewModel( @@ -151,10 +157,21 @@ fun SignInToProfileScreen( text = stringResource(Res.string.signing_you_in) ) - is SignInToProfileUIState.Error -> ErrorState( - message = problemMessage(state.problem), - onRetry = { viewModel.retry() }, - ) + is SignInToProfileUIState.Error -> Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ErrorState( + message = problemMessage(state.problem), + onRetry = { viewModel.retry() }, + ) + // "Already on this device" is a sentence with an obvious next step. + state.alreadyHere?.let { id -> + TextButton(onClick = { onSignedIn(id) }) { + Text(stringResource(Res.string.switch_to_it)) + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 0d80ba68..d0e04ab7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -29,6 +29,7 @@ import press.mantra.compose.identity.Identity import press.mantra.compose.identity.IdentityKind import press.mantra.compose.identity.StoredIdentity import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.wallet.AddProfileRows import press.mantra.compose.ui.composable.widgets.wallet.rememberProfilesOf import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.Res @@ -52,6 +53,9 @@ fun SovereignWalletStartupScreen( nostrRepository: NostrRepository, onNavigateToWalletLandingPage: () -> Unit, onSuccessfulStartup: () -> Unit, + /** The two ways to add a profile from the selector; a device with one profile can be given a second without opening the first. */ + onNavigateToSignIn: () -> Unit, + onNavigateToCreateProfile: () -> Unit, forceWalletId: WalletId?, ) { val sovereignWalletStartupViewModel = viewModel( @@ -168,8 +172,13 @@ fun SovereignWalletStartupScreen( Spacer(Modifier.height(MaterialTheme.spacing.space200)) }, bottomContent = { + AddProfileRows( + onSignIn = onNavigateToSignIn, + onCreateProfile = onNavigateToCreateProfile, + caption = false, + ) // m3-spacing-exempt: room to scroll the - // last wallet clear of the bottom of the + // last row clear of the bottom of the // window, not a step in the spacing // rhythm. The scale tops out at 72dp and // rounding to it would put the last row diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 6d05f453..ad9e3731 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -90,6 +90,8 @@ import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.ProfilesScreen import press.mantra.compose.ui.view.model.ProfilesViewModel import press.mantra.compose.ui.view.model.getAvailableWalletsMeta +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.model.seedProfileWriter import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute @@ -475,6 +477,12 @@ fun MantraNavHost( navigationViewModel.loadNostrProfile(route) } }, + onNavigateToSignIn = { + navController.navigate(route = SignInRoute) + }, + onNavigateToCreateProfile = { + navController.navigate(route = CreateProfileRoute(withWallet = false)) + }, forceWalletId = null, ) } @@ -499,47 +507,41 @@ fun MantraNavHost( route = SignInRoute ) }, + // The first profile on a device is the one the wallet will belong to. onNavigateToCreateProfile = { navController.navigate( - route = CreateProfileRoute() + route = CreateProfileRoute(withWallet = true) ) } ) } - composable { + composable { backStackEntry -> + val route = backStackEntry.toRoute() CreateProfileScreen( - onNavigateToEndThis = { - navController.navigate( - route = BlankRoute - ) { - popUpTo(0) - } - }, onNavigateBack = { navController.popBackStack() }, nostrRepository = databaseNostrRepository, marmotRepository = databaseMarmotRepository, - writeSeed = { words, onSeedWritten, onSeedWriteError -> - sovereignWalletViewModel.writeSeed( - words, - isRestoringWallet = false, - onSeedWritten = { walletId -> - onSeedWritten() - - sovereignWalletViewModel.loadSovereignData(walletId) - sovereignWalletViewModel.listIdentities { - sovereignWalletViewModel.switchToIdentity(walletId) - navController.navigate( - route = SovereignWalletStartupRoute - ) - } - }, - onSeedWriteError = { onSeedWriteError() } - ) - } + // A seed from Landing, a bare key from inside: the screen does not know + // which. See docs/multiple-profiles.md, Phase 5. + newProfile = if (route.withWallet) sovereignWalletViewModel.seedProfileWriter() else sovereignWalletViewModel.bareKeyProfileWriter(), + // The sign-in tail, with the popUpTo(0) it always had there: back from the + // new profile's startup must not return to a form whose secret is already + // on disk. The account rows are in the database before this runs, so the + // machine finds them the moment the identity is activated. + onProfileCreated = { walletId -> + sovereignWalletViewModel.loadSovereignData(walletId) + sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToIdentity(walletId) + navController.navigate( + route = SovereignWalletStartupRoute + ) { + popUpTo(0) + } + } + }, ) - } composable { backStackEntry -> val route = backStackEntry.toRoute() @@ -917,6 +919,12 @@ fun MantraNavHost( onRetry = { sovereignWalletViewModel.listIdentities {} }, + onSignIn = { + navController.navigate(route = SignInRoute) + }, + onCreateProfile = { + navController.navigate(route = CreateProfileRoute(withWallet = false)) + }, ) } composable { backStackEntry -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt index b0ea6406..b05b9442 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CreateProfileRoute.kt @@ -2,7 +2,13 @@ package press.mantra.compose.ui.composable.navigation.routes import kotlinx.serialization.Serializable +/** + * @param withWallet whether the profile is made of a seed, with a wallet attached, or of + * a bare key. True from Landing, where the first profile on a device is the one the + * wallet will belong to; false from the switcher, where wanting another profile is not + * wanting another wallet. See docs/multiple-profiles.md, Phase 5. + */ @Serializable data class CreateProfileRoute( - val nostrEventId: String? = null, -): Route() \ No newline at end of file + val withWallet: Boolean, +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt new file mode 100644 index 00000000..c21e969b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/AddProfileRows.kt @@ -0,0 +1,75 @@ +package press.mantra.compose.ui.composable.widgets.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.create_a_new_profile +import mantra.composeapp.generated.resources.sign_in_with_a_key +import mantra.composeapp.generated.resources.the_profile_you_are_in_stays_on_this_device +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.spacing + +/** + * The two ways to add a profile, under the list of the ones the device has -- on the + * switcher and on the startup selector alike. Phase 5 of docs/multiple-profiles.md. + * + * Both push a screen that has a back button. Landing is not one of them: it has no back + * button and clears the stack on its way out, because it is the first-run screen, and + * pushing it from inside would make it a pushed screen on some days and a root on + * others. + * + * @param caption said under the rows where there is a profile to say it of -- the + * switcher -- and omitted on the startup selector, where nothing is open. + */ +@Composable +fun AddProfileRows( + onSignIn: () -> Unit, + onCreateProfile: () -> Unit, + caption: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(MaterialTheme.spacing.itemGap)) + HorizontalDivider() + Spacer(Modifier.height(MaterialTheme.spacing.itemGap)) + TextButton(onClick = onSignIn) { + Icon(Icons.Default.Key, contentDescription = Decorative) + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.sign_in_with_a_key)) + } + TextButton(onClick = onCreateProfile) { + Icon(Icons.Default.PersonAdd, contentDescription = Decorative) + Spacer(Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.create_a_new_profile)) + } + if (caption) { + Text( + text = stringResource(Res.string.the_profile_you_are_in_stays_on_this_device), + modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt index dce2283c..7c2ec497 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt @@ -12,20 +12,13 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.CreateProfileUIState import press.mantra.compose.ui.view.state.form.CreateProfileFormState import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.MnemonicCode -import fr.acinq.bitcoin.byteVector -import fr.acinq.lightning.Lightning -import fr.acinq.lightning.crypto.LocalKeyManager -import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.nostrPublicKey -import fr.acinq.phoenix.managers.nsecPassword -import fr.acinq.phoenix.utils.MnemonicLanguage +import fr.acinq.phoenix.data.WalletId import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withContext +import press.mantra.compose.identity.NewProfileWriter class CreateProfileViewModel( val initialCreateProfileUIState: CreateProfileUIState, @@ -78,19 +71,24 @@ class CreateProfileViewModel( } - private fun failProfileCreation(e: Throwable? = null) { - if (e != null) { - logger.e("error when creating new profile: ", e) - } else { - logger.e { "seed write failed, aborting profile creation" } - } + private fun failProfileCreation(e: Throwable) { + logger.e("error when creating new profile: ", e) createProfileUIState.value = CreateProfileUIState.Error isActionPending.value = false } - fun createAccount( - writeSeed: (mnemonics: List, onSeedWritten: () -> Unit, onSeedWriteError: () -> Unit) -> Unit - ) { + /** + * Makes the profile: the secret through [newProfile], then the six bootstrap events + * for the key it derives, then [onProfileCreated] with the id, on the main thread. + * + * The secret is written first, so that the events are only ever queued for a key the + * device holds and the notary can sign; and the events are in the database before the + * caller's tail activates the identity, so that the navigation machine finds the + * account the moment it looks -- an ordering the sign-in screen documents as + * load-bearing and the create flow used to get away with by luck. Which secret is made + * -- a seed, or a bare key -- is the writer's business; see [NewProfileWriter]. + */ + fun createAccount(newProfile: NewProfileWriter, onProfileCreated: (WalletId) -> Unit) { logger.d { "createAccount" } if (isActionPending.value) return isActionPending.value = true @@ -98,52 +96,21 @@ class CreateProfileViewModel( viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> failProfileCreation(e) }) { - logger.d("generating new wallet...") - val entropy = Lightning.randomBytes(16) - val mnemonics = MnemonicCode.toMnemonics( - entropy = entropy, - wordlist = MnemonicLanguage.English.wordlist() + val written = newProfile.write().getOrElse { e -> + failProfileCreation(e) + return@launch + } + logger.d("Pubkey: ${written.nostrPublicKey}") + + nostrRepository.createNewProfile( + written.nostrPublicKey, + name = createProfileFormState.nameField.textFieldState.text.toString(), + biography = createProfileFormState.biographyField.textFieldState.text.toString(), + onCompletion = { + logger.d("Created: ${written.nostrPublicKey}") + } ) - - val localKeyManager = LocalKeyManager( - seed = MnemonicCode.toSeed(mnemonics, "").byteVector(), - chain = NodeParamsManager.chain, - remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub - ) - - val pubkey = localKeyManager.nostrPublicKey() - - logger.d("Pubkey: $pubkey" ) - - // Only create the profile events once the seed backing this pubkey is safely - // on disk; otherwise the notary could never sign them. - writeSeed( - mnemonics, - { - viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e -> - failProfileCreation(e) - }) { - nostrRepository.createNewProfile( - pubkey, - name = createProfileFormState.nameField.textFieldState.text.toString(), - biography = createProfileFormState.biographyField.textFieldState.text.toString(), - onCompletion = { - logger.d("Created: $pubkey" ) - } - ) - } - }, - { failProfileCreation() } - ) - } - } - - fun wipeDatabase( - onNavigateToEndThis: () -> Unit - ) { - viewModelScope.launch { - nostrRepository.wipeDatabase() - onNavigateToEndThis.invoke() + withContext(Dispatchers.Main) { onProfileCreated(written.id) } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt new file mode 100644 index 00000000..11d06d87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NewProfileWriters.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.ui.view.model + +import fr.acinq.bitcoin.MnemonicCode +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.MnemonicLanguage +import kotlinx.coroutines.CompletableDeferred +import press.mantra.compose.identity.IdentityWriter +import press.mantra.compose.identity.NewProfile +import press.mantra.compose.identity.NewProfileWriter +import press.mantra.compose.identity.StoredIdentity + +/** + * A profile with a wallet attached: twelve fresh words through the seed writer, which + * since docs/multiple-profiles.md writes the derived key to the credentials file as + * well. Callback-shaped underneath, awaited here: exactly one of the two callbacks + * fires. + */ +fun SovereignWalletViewModel.seedProfileWriter(): NewProfileWriter = NewProfileWriter { + val words = MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist()) + val outcome = CompletableDeferred>() + writeSeed( + mnemonics = words, + isRestoringWallet = false, + onSeedWritten = { id -> outcome.complete(Result.success(NewProfile(StoredIdentity.nostrPublicKeyOf(words), id))) }, + onSeedWriteError = { error -> outcome.complete(Result.failure(IllegalStateException("seed write failed: $error"))) }, + ) + outcome.await() +} + +/** A profile that is a key: thirty-two random bytes, written the way a pasted nsec is. */ +fun SovereignWalletViewModel.bareKeyProfileWriter(): NewProfileWriter = NewProfileWriter { + val key = PrivateKey(Lightning.randomBytes(32)) + when (val result = writeNostrKey(key)) { + is IdentityWriter.WriteNostrCredentialResult.Written -> Result.success(NewProfile(key.nostrPublicKeyHex(), result.id)) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Result.failure(IllegalStateException("a fresh key collided with one already here")) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Result.failure(IllegalStateException("the credentials could not be read")) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt index 82e9a8be..4155310c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt @@ -104,13 +104,15 @@ class SignInToProfileViewModel( /** The outcome of [commit], for the screen to act on and the tests to assert. */ sealed interface Outcome { data class SignedIn(val id: WalletId) : Outcome - data class Failed(val problem: CredentialProblem) : Outcome + + /** @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is listed under. */ + data class Failed(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : Outcome } private suspend fun write(what: String, writer: suspend () -> IdentityWriter.WriteNostrCredentialResult): Outcome = try { when (val result = writer()) { is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) - is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = result.id) is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) } } catch (e: CancellationException) { @@ -141,13 +143,11 @@ class SignInToProfileViewModel( { id -> outcome.complete(Outcome.SignedIn(id)) }, { error -> outcome.complete( - Outcome.Failed( - when (error) { - is WritingSeedState.Error.SeedAlreadyExists -> CredentialProblem.AlreadyOnThisDevice - is WritingSeedState.Error.CannotLoadSeedMap, - is WritingSeedState.Error.Generic -> CredentialProblem.CouldNotWrite - } - ) + when (error) { + is WritingSeedState.Error.SeedAlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere = error.id) + is WritingSeedState.Error.CannotLoadSeedMap, + is WritingSeedState.Error.Generic -> Outcome.Failed(CredentialProblem.CouldNotWrite) + } ) }, ) @@ -170,7 +170,7 @@ class SignInToProfileViewModel( viewModelScope.launch(Dispatchers.IO) { when (val outcome = commit(credential)) { is Outcome.SignedIn -> withContext(Dispatchers.Main) { onSignedIn(outcome.id) } - is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem) + is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem, outcome.alreadyHere) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 2a9dbe37..1b0ea520 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -52,7 +52,8 @@ sealed class WritingSeedState { sealed class Error : WritingSeedState() { data class Generic(val cause: Throwable) : Error() data object CannotLoadSeedMap: Error() - data object SeedAlreadyExists: Error() + /** [id] is the wallet's, so that a refusal from inside the app can offer to switch to it. */ + data class SeedAlreadyExists(val id: WalletId): Error() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt index fb233399..1710d0e2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SignInToProfileUIState.kt @@ -1,5 +1,6 @@ package press.mantra.compose.ui.view.state +import fr.acinq.phoenix.data.WalletId import press.mantra.compose.identity.CredentialProblem import press.mantra.compose.identity.SignInCredential @@ -23,5 +24,10 @@ sealed interface SignInToProfileUIState { /** Writing the secret and planting the account. */ data object Committing : SignInToProfileUIState - data class Error(val problem: CredentialProblem) : SignInToProfileUIState + /** + * @param alreadyHere for [CredentialProblem.AlreadyOnThisDevice], the id the key is + * listed under: "already on this device" is a sentence with an obvious next step, + * and from inside the app the screen offers it. + */ + data class Error(val problem: CredentialProblem, val alreadyHere: WalletId? = null) : SignInToProfileUIState } diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt new file mode 100644 index 00000000..74991562 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/JvmGlobalPrefs.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.extensions + +import fr.acinq.bitcoin.Chain +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.utils.preferences.GlobalPrefs + +/** + * The one way the jvm target reaches the global preferences. + * + * `DataStoreManager.loadGlobalPrefsForWallet` caches one `GlobalPrefs` per process behind + * an unsynchronised check-then-set, and DataStore refuses a second instance over the same + * file. Two first calls at once -- the listing on IO and the startup screen on Main, or a + * sign-in's write and the listing it races -- can both see the empty cache, both build + * one, and the loser throws "multiple DataStores active for the same file" on first use. + * Android is unaffected, since it reaches the prefs through the Application's single + * instance; the jvm actuals all went through the cache, and this serialises them. The + * library's own callers come later, at node start, when the cache is long populated. + */ +internal object JvmGlobalPrefs { + private val lock = Any() + + fun of(phoenixGlobal: PhoenixGlobal): GlobalPrefs = synchronized(lock) { + DataStoreManager(phoenixGlobal.ctx, chain = Chain.Mainnet).loadGlobalPrefsForWallet() + } +} diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt index d47aa7ec..31d70efd 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/extensions/Phoenix.jvm.kt @@ -1,11 +1,9 @@ package press.mantra.compose.extensions import co.touchlab.kermit.Logger -import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.StartBusinessResult import fr.acinq.phoenix.jvm.BusinessManager -import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.utils.preferences.GlobalPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -44,9 +42,9 @@ actual fun schedulePlatformLogic(phoenixGlobal: PhoenixGlobal) { } actual fun getShowIntroFlow(phoenixGlobal: PhoenixGlobal): Flow { - return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet().getShowIntro + return JvmGlobalPrefs.of(phoenixGlobal).getShowIntro } actual fun getGlobalPrefs(phoenixGlobal: PhoenixGlobal): GlobalPrefs { - return DataStoreManager(phoenixGlobal.ctx, Chain.Mainnet).loadGlobalPrefsForWallet() + return JvmGlobalPrefs.of(phoenixGlobal) } diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt index acea8fb4..1d9fa171 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -8,15 +8,14 @@ package press.mantra.compose.ui.view.model -import fr.acinq.bitcoin.Chain import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.jvm.BusinessManager -import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.SeedManager import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import kotlinx.coroutines.flow.Flow +import press.mantra.compose.extensions.JvmGlobalPrefs actual fun updateBusinessActiveInUI(walletId: WalletId) { @@ -34,20 +33,14 @@ actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult { } actual fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow> { - return DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().getAvailableWalletsMeta + return JvmGlobalPrefs.of(phoenixGlobal).getAvailableWalletsMeta } actual suspend fun saveAvailableWalletMeta( phoenixGlobal: PhoenixGlobal, metadata: UserWalletMetadata ) { - DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().saveAvailableWalletMeta(metadata) + JvmGlobalPrefs.of(phoenixGlobal).saveAvailableWalletMeta(metadata) } actual suspend fun saveAvailableWalletMeta( @@ -57,10 +50,7 @@ actual suspend fun saveAvailableWalletMeta( avatar: String, isHidden: Boolean ) { - DataStoreManager( - phoenixGlobal.ctx, - chain = Chain.Mainnet - ).loadGlobalPrefsForWallet().saveAvailableWalletMeta( + JvmGlobalPrefs.of(phoenixGlobal).saveAvailableWalletMeta( walletId = walletId, name = name, avatar = avatar, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt new file mode 100644 index 00000000..604b095c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/AddProfileFromInsideJvmTest.kt @@ -0,0 +1,186 @@ +package press.mantra.compose.identity + +import androidx.room3.Room +import co.touchlab.kermit.Logger +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.ui.view.model.CreateProfileViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.state.CreateProfileUIState +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A profile added from inside -- Phase 5 of docs/multiple-profiles.md -- through the + * real view models on an in-memory database. + * + * A is open. B's nsec is committed through the sign-in view model, as the switcher's + * "sign in with a key" row leads to; the tail switches; B is open; and back. Both + * accounts are intact, each with its own kind 0 and neither with the other's. Then a + * profile created through the create view model with the bare-key writer, as the + * switcher's "create a new profile" row leads to: it lists as a bare key, its account + * holds the six bootstrap rows, and no node ran -- the assertion the nsec round trip + * already makes, made of a profile the app made rather than one it was handed. + */ +class AddProfileFromInsideJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val log = Logger.withTag("AddProfileFromInsideJvmTest") + private val keyA = PrivateKey(Lightning.randomBytes(32)) + private val keyB = PrivateKey(Lightning.randomBytes(32)) + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-add-inside-store").toFile() + appDir = Files.createTempDirectory("mantra-add-inside-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own: DataStoreManager caches one per process, and + // when this class is the first in the JVM to populate it, a second instance over + // the same file is exactly what DataStore refuses. Everything here goes through + // the view model, and so through the cache. + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private fun sovereign() = SovereignWalletViewModel(phoenixGlobal, stopBusiness = {}) + + private fun signInViewModel(sovereign: SovereignWalletViewModel) = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> sovereign.writeNostrKey(key) }, + writeNostrPublicKey = { pubkey -> sovereign.writeNostrPublicKey(pubkey) }, + writeRecoveryPhrase = { _, _, onError -> onError(press.mantra.compose.ui.view.model.WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + private fun activated(stored: StoredIdentity.NostrSecret): Identity { + val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) + return Identity.signing( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + } + + private suspend fun listed(sovereign: SovereignWalletViewModel, size: Int): Map { + val done = CompletableDeferred() + sovereign.listIdentities { done.complete(Unit) } + withTimeout(15_000) { done.await() } + return sovereign.availableIdentities.first { it.size == size } + } + + private suspend fun accountsOf(key: PrivateKey) = + nostrRepository.getLocalAccounts().filter { it.unsignedNostrEvent?.pubKey == key.nostrPublicKeyHex() } + + @Test + fun `a key signed in from inside is switched to, and the profile it was signed in from stays`() = runBlocking { + // A is open, the way the app gets there. + val sovereign = sovereign() + val signedInA = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyA, keyA.nostrPublicKeyHex())) + ) + val storedA = assertIs(listed(sovereign, 1)[signedInA.id]) + sovereign.setActiveIdentity(activated(storedA)) + + // B, from the switcher's row: the sign-in screen's commit, then its tail. + val signedInB = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyB, keyB.nostrPublicKeyHex())) + ) + val identities = listed(sovereign, 2) + val storedB = assertIs(identities[signedInB.id]) + assertIs(identities[signedInA.id], "the profile signed in from is still listed") + sovereign.switchToIdentity(signedInB.id) + assertNull(sovereign.activeIdentity.value) + sovereign.setActiveIdentity(activated(storedB)) + assertEquals(keyB.nostrPublicKeyHex(), sovereign.activeIdentity.value?.nostrPublicKey) + + // And back. + sovereign.switchToIdentity(signedInA.id) + sovereign.setActiveIdentity(activated(storedA)) + assertEquals(keyA.nostrPublicKeyHex(), sovereign.activeIdentity.value?.nostrPublicKey) + + // Both accounts intact, each with its own kind 0 and neither with the other's. + assertEquals(1, accountsOf(keyA).size) + assertEquals(1, accountsOf(keyB).size) + assertEquals(2, nostrRepository.getLocalAccounts().size) + + // Pasting A's key again from inside is refused with the id A is listed under. + val again = assertIs( + signInViewModel(sovereign).commit(SignInCredential.NostrSecret(keyA, keyA.nostrPublicKeyHex())) + ) + assertEquals(CredentialProblem.AlreadyOnThisDevice, again.problem) + assertEquals(signedInA.id, again.alreadyHere, "the refusal names the profile to switch to") + } + + @Test + fun `a profile created from inside is a bare key with its six bootstrap rows, and no node ran`() = runBlocking { + val sovereign = sovereign() + val created = CompletableDeferred() + val viewModel = CreateProfileViewModel( + initialCreateProfileUIState = CreateProfileUIState.ConfirmInput(name = "Bea", bio = "Second"), + nostrRepository = nostrRepository, + marmotRepository = marmotRepository, + ) + viewModel.createProfileFormState.nameField.textFieldState.edit { append("Bea") } + viewModel.createProfileFormState.biographyField.textFieldState.edit { append("Second") } + + viewModel.createAccount(sovereign.bareKeyProfileWriter()) { created.complete(it) } + val id = withTimeout(15_000) { created.await() } + + val stored = assertIs(listed(sovereign, 1)[id], "a key, not a wallet") + val account = nostrRepository.getLocalAccounts().single { it.unsignedNostrEvent?.pubKey == stored.nostrPublicKey } + assertNull(account.unsignedNostrEvent?.signedAt, "the kind 0 waits for the notary, which starts when the profile is opened") + val queued = nostrRepository.observeUnsignedNostrEvents(stored.nostrPublicKey).first() + assertEquals(6, queued.size, "the bootstrap: the kind 0, the contact list, and the relay lists") + assertEquals(0, queued.first().kind, "the kind 0 first") + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no PhoenixBusiness was started for a bare key") + assertEquals(CreateProfileUIState.ConfirmInput(name = "Bea", bio = "Second"), viewModel.createProfileUIState.value, "the screen leaves through the tail, not an error") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt index 8a9aed7b..0e94f355 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt @@ -1,6 +1,5 @@ package press.mantra.compose.identity -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.room3.Room import co.touchlab.kermit.Logger import fr.acinq.bitcoin.PrivateKey @@ -11,11 +10,10 @@ import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.jvm.BusinessManager import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.computePreferencePath import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.security.JvmKeyStore import fr.acinq.phoenix.utils.PlatformContext -import fr.acinq.phoenix.utils.preferences.GlobalPrefs +import press.mantra.compose.extensions.getGlobalPrefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -72,7 +70,6 @@ class IdentitySwitchJvmTest { private lateinit var storeDir: File private lateinit var appDir: File private lateinit var phoenixGlobal: PhoenixGlobal - private lateinit var globalPrefs: GlobalPrefs private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) private val scope = CoroutineScope(Job() + Dispatchers.IO) @@ -92,12 +89,10 @@ class IdentitySwitchJvmTest { appDir = Files.createTempDirectory("mantra-switch-app").toFile() JvmKeyStore.lock() JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own -- see AddProfileFromInsideJvmTest.setUp: the + // process-wide cache and a second instance over the same file is what DataStore + // refuses, and which of the two this class gets depends on the order of the run. phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) - globalPrefs = GlobalPrefs( - PreferenceDataStoreFactory.createWithPath { - computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb") - } - ) } @AfterTest @@ -111,7 +106,7 @@ class IdentitySwitchJvmTest { private suspend fun signIn(key: PrivateKey): WalletId { val written = assertIs( - IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, key, isTorEnabled = false, customElectrumServer = null) + IdentityWriter.writeNostrKey(log, phoenixGlobal, getGlobalPrefs(phoenixGlobal), key, isTorEnabled = false, customElectrumServer = null) ) nostrRepository.signInToProfile(key.nostrPublicKeyHex()) return written.id diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt index e19b2f54..dc61a584 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt @@ -166,7 +166,7 @@ class IdentityWriterJvmTest { assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Secret(first)), credentials()) assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)), "the preferences file the recovery screens read") - assertIs(write(first)) + assertEquals(stored.id, assertIs(write(first)).id) } @Test @@ -267,8 +267,9 @@ class IdentityWriterJvmTest { assertEquals(setOf(phrase.walletId), seeds()?.keys) assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb"))) - assertIs(write(phrase.nostrKey)) - assertIs(writePublic(phrase.nostrKey)) + // Refused with the wallet's id, not the bare key's: the id the profile is listed under. + assertEquals(phrase.walletId, assertIs(write(phrase.nostrKey)).id) + assertEquals(phrase.walletId, assertIs(writePublic(phrase.nostrKey)).id) assertIs( IdentityWriter.forgetNostrCredential(log, phoenixGlobal, id, phrase.nostrPublicKey) ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt new file mode 100644 index 00000000..cac213bf --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CreateProfileScreenJvmTest.kt @@ -0,0 +1,49 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.identity.NewProfileWriter +import press.mantra.compose.repository.MarmotRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.CreateProfileUIState +import kotlin.test.Test + +/** + * What the create screen no longer offers -- Phase 5 of docs/multiple-profiles.md. + * + * "End this" wiped the database: every profile's rooms, MLS state, key packages and + * queues, from a screen whose purpose is to add a profile. With several profiles on a + * device it was a way to lose the others. The state that carried it is composed and + * looked at; the words are not there. + */ +@OptIn(ExperimentalTestApi::class) +class CreateProfileScreenJvmTest { + + @Test + fun `the screen that adds a profile cannot wipe the others`() = runDesktopComposeUiTest(400, 900) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CreateProfileScreen( + initialCreateProfileUIState = CreateProfileUIState.ProfileReady( + profile = Profile(publicKey = "ab".repeat(32), nostrEventId = "e".repeat(64), displayName = "Alan"), + ), + onNavigateBack = {}, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + marmotRepository = MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE, + newProfile = NewProfileWriter { Result.failure(IllegalStateException("not reached")) }, + onProfileCreated = {}, + ) + } + } + } + + onNodeWithText("Profile is ready", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("End this", useUnmergedTree = true).assertDoesNotExist() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt index c855cc83..1d5a284b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilesScreenJvmTest.kt @@ -86,6 +86,7 @@ class ProfilesScreenJvmTest { } private val switched = mutableListOf() + private val taps = mutableListOf() private fun viewModel(listState: ListWalletState = ListWalletState.Success) = ProfilesViewModel( listWalletState = MutableStateFlow(listState), @@ -104,6 +105,8 @@ class ProfilesScreenJvmTest { onNavigateBack = {}, onSwitch = { switched += it }, onRetry = onRetry, + onSignIn = { taps += "sign in" }, + onCreateProfile = { taps += "create" }, ) } } @@ -157,6 +160,20 @@ class ProfilesScreenJvmTest { assertEquals(1, retried) } + /** The two ways to add a profile, under the list, each pushing its own screen. */ + @Test + fun `under the list are the two ways to add a profile, and the one that is open stays`() = runDesktopComposeUiTest(400, 900) { + setContent { Screen(viewModel()) } + awaitLoaded() + + onNodeWithText("The profile you are in stays on this device.", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Sign in with a key", useUnmergedTree = true).performClick() + onNodeWithText("Create a new profile", useUnmergedTree = true).performClick() + + assertEquals(listOf("sign in", "create"), taps) + assertEquals(emptyList(), switched, "adding is not switching") + } + @Test fun `the list is a pushed screen, with a way back`() = runDesktopComposeUiTest(400, 900) { setContent { Screen(viewModel()) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt index 94c086ef..953a10f7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt @@ -29,6 +29,9 @@ class SignInToProfileViewModelJvmTest { private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ") private val id = WalletId("ab".repeat(20)) + /** The id a key already on the device is listed under, which the refusal carries so the screen can offer to switch to it. */ + private val alreadyHere = WalletId("cd".repeat(20)) + private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey) private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, publicKey) private val nostrPublicKey = SignInCredential.NostrPublicKey(publicKey) @@ -67,9 +70,9 @@ class SignInToProfileViewModelJvmTest { fun `the npub of a key already here is refused, and no account is planted`() = runBlocking { val recorder = Recorder() - val outcome = viewModel(recorder, publicKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrPublicKey) + val outcome = viewModel(recorder, publicKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists(alreadyHere) }).commit(nostrPublicKey) - assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) assertEquals(listOf("writeNostrPublicKey"), recorder.effects) } @@ -97,9 +100,9 @@ class SignInToProfileViewModelJvmTest { fun `a key already on the device is refused, and no account is planted`() = runBlocking { val recorder = Recorder() - val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrSecret) + val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists(alreadyHere) }).commit(nostrSecret) - assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) assertEquals(listOf("writeNostrKey"), recorder.effects) } @@ -107,10 +110,10 @@ class SignInToProfileViewModelJvmTest { fun `a seed already on the device is the same refusal`() = runBlocking { val recorder = Recorder() - val outcome = viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.SeedAlreadyExists) }) + val outcome = viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.SeedAlreadyExists(alreadyHere)) }) .commit(recoveryPhrase) - assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice, alreadyHere), outcome) assertEquals(listOf("writeRecoveryPhrase"), recorder.effects) } From ae8cde889e1d019480d74db97db221e76f395fe3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 15:51:38 +0200 Subject: [PATCH 49/71] docs: record Phase 3 of the profiles pull, and the one resolution Four commits landed as 84ac84cc, 73a0e5b9, 5654e462 and ccb9942a. Three were clean picks; the fourth is the conflict the dry run found and the third decision explained -- CreateProfileViewModel.kt at 2f420230, where Mantra's 39fb64b6 had moved one import and upstream's commit rewrites the whole block and removes the derivation that import served. Taken theirs, by the rule added to the driver, and the file is now identical on both sides: the residual between the trees went from 136 files to 135, that file being the one that left, and no other file's residual moved. Both compilers clean. jvmTest 837 -> 858, testDebugUnitTest 413 -> 420 (StartupChoiceTest), every audit budget met, and the audit's count of composable string literals -- reported without a budget -- fell from 40 to 30 as the startup screen's wallet-worded literals moved into the catalogue; strings.xml 444 -> 455. The review points the phase named were read rather than assumed: the relay observer is a single collectLatest child with no per-pubkey map; the switch sets the desired id, sets startWalletImmediately back to true, clears the identity and only then stops a node, and only when the profile being left had one; the startup precedence reads force, desired, "show me the list", the only one, the default; CreateProfileRoute(withWallet = true) appears once, under Landing; the jvm target reaches the global preferences through JvmGlobalPrefs alone; the switcher's bar carries NavigateBackButton. Co-Authored-By: Claude Opus 5 --- docs/curated-to-mantra-profiles.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md index ac5a29e9..72cdd26a 100644 --- a/docs/curated-to-mantra-profiles.md +++ b/docs/curated-to-mantra-profiles.md @@ -425,6 +425,23 @@ switcher and switched to; the previous node is stopped once; a cold boot opens t last-used profile without asking; `IdentitySwitchJvmTest`, `ProfilesScreenJvmTest`, `AddProfileFromInsideJvmTest`, `CreateProfileScreenJvmTest` green. +**Built 2026-09-13**, `84ac84cc`, `73a0e5b9`, `5654e462`, `ccb9942a`: three clean picks +and the one the rule was written for — `2f420230` × `CreateProfileViewModel.kt`, taken +theirs, with the reason in its message. Exactness: 136 → 135 residual files, the one +gone being that file, as the third decision said; nothing else moved. Both compilers +clean; `jvmTest` 837 → 858 (`IdentitySwitchJvmTest`, `RelaysSocketManagerSwitchJvmTest`, +`ProfilesScreenJvmTest`, `AddProfileFromInsideJvmTest`, `CreateProfileScreenJvmTest`, and +the three added to `ReadOnlyEntrancesJvmTest`); `testDebugUnitTest` 413 → 420, the seven +being `StartupChoiceTest`; audit all budgets met, and the composable-literal count the +audit reports without a budget went 40 → 30 as the startup screen's words moved to the +catalogue; `strings.xml` 444 → 455. Read in review, as the phase asked: the observer is +one `collectLatest` child with no map; `switchToIdentity` sets the desired id, sets +`startWalletImmediately` back, clears the identity, and only then stops a node, and only +if there was one; `StartupChoice.resolve` reads *force, desired, "show me the list", +the only one, the default*; `CreateProfileRoute(withWallet = true)` appears once, under +Landing; the jvm target reaches `loadGlobalPrefsForWallet` through `JvmGlobalPrefs` +alone; `ProfilesScreen` puts `NavigateBackButton` in its bar. + ### Phase 4 — the queue owner and the inbox, with the migration `759199f2`, alone, because it is the schema change and upstream's rollout says it can From 72afe8aa40a0f4f0d5191d8fd9691206cb123ef1 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 01:41:14 +0200 Subject: [PATCH 50/71] feat(sync): a fetch queue per identity, and the inbox reopened on activation Phase 6 of docs/multiple-profiles.md. The one data problem, closed from both ends. The two queues that fetch -- SynchronizeNostrEventRequest and NegentropySynchronizeRequest -- gain a nullable ownerPublicKey, the identity that asked, because what a fetch brings back is opened with the fetcher's key. The pumps ask for their own: the head of the queue among this identity's requests and the ones nobody owns, so that a request another identity queued waits for that identity. Rows from before the column read back null, meaning "the device's", which is what they were, and any identity may drain them. The broadcast queue deliberately gets no owner: a signed event is anyone's to carry, and holding A's outgoing message until A is opened again would be a delivery failure the user would never be told about. Room version 20, an AutoMigration for two nullable columns, with the schema export committed beside its predecessors. The stamp is the repository's, not the call site's. Eighteen view models and two DAO paths queue requests, and every one of them does so as the active identity -- a screen cannot queue anything as anyone else. DatabaseNostrRepository takes the identity flow at construction, which meant moving the view model above the repositories in the nav host, and stamps every request it queues where the caller stamped nothing; a negentropy request carries its owner into the REQ it becomes. The DAO's own requests -- the placeholder-profile syncs it plants while indexing, and the participant syncs of a Marmot join -- stay unowned, on purpose and against the plan's sketch: they fetch public kinds that need no key to open, and any profile that is open may as well fetch them. The other end: wraps that were fetched under the wrong key -- before this, or by a read-only identity whose nsec was pasted later, the case the npub plan's DAO guard left with the words "the key that opens this one may be signed in later" and no code behind them. storeNostrEvent never indexes an event it already holds, so a wrap that arrived under the wrong key stayed closed for good: the live subscription re-received it, the DAO saw a known id, and returned. NostrDao.reopenInbox finds every wrap addressed to the active key that no seal names and runs each through indexNostrEvent again under the right key, one transaction per wrap so that one that cannot be opened rolls back its own changes and the next is still tried; one pass, since wraps do not depend on one another. It runs from the sync pumps' collector once per activation, for an identity that can sign, before the live inbox and the broadcast pump are launched -- so the sweep and the live subscription are not opening the same wrap at once; both are idempotent by event id. Found on the way: GiftWrapSeal.giftWrapMessageId, a foreign key to the wrap a seal came out of, existed and was never written, so nothing in the database could say which wraps had been opened. decryptGiftWrapSeal now sets it. A seal from before reads back null, is swept once, and comes back with the link -- the reindex is idempotent, and persistInboundChatMessage already files a message it has once. Tests: ReadOnlyGiftWrapDaoJvmTest -- a wrap for us stored under another profile's key is stored and not opened, a re-delivery under our key changes nothing, the sweep opens it once and finds nothing the second time; the other profile's sweep and a read-only pair open nothing; a wrap stored read-only is opened once the key is here. OwnedRequestQueueJvmTest -- A's request is offered to A and not to B, nobody's to both, oldest first; the repository stamps what it queues with the identity that is open, nothing when none is, and keeps an owner a caller named. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@759199f2af543bd5ea6e39069f95ab2472337bfd --- .../20.json | 5698 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 11 +- .../database/dao/GiftWrapMessageDao.kt | 12 + .../compose/database/dao/GiftWrapSealDao.kt | 3 + .../dao/NegentropySynchronizeRequestDao.kt | 11 + .../mantra/compose/database/dao/NostrDao.kt | 53 + .../database/dao/NostrEventRelayDao.kt | 5 + .../dao/SynchronizeNostrEventRequestDao.kt | 15 + .../compose/database/model/GiftWrapMessage.kt | 7 +- .../model/NegentropySynchronizeRequest.kt | 4 + .../model/SynchronizeNostrEventRequest.kt | 9 + .../repository/DatabaseNostrRepository.kt | 38 +- .../compose/repository/NostrRepository.kt | 18 +- .../ui/composable/navigation/MantraNavHost.kt | 17 +- .../ui/view/model/SynchronizationViewModel.kt | 14 +- .../database/dao/OwnedRequestQueueJvmTest.kt | 138 + .../dao/ReadOnlyGiftWrapDaoJvmTest.kt | 63 +- 17 files changed, 6093 insertions(+), 23 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/OwnedRequestQueueJvmTest.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json new file mode 100644 index 00000000..8095b070 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json @@ -0,0 +1,5698 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "4414c3734feef178b15710349b993246", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatMessage_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "joinedGroupAt", + "columnName": "joinedGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKeys", + "columnName": "participantPublicKeys", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `parentChatRoomId` TEXT, `birthCertificateJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4414c3734feef178b15710349b993246')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index 39968228..dcd795cd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 19, + version = 20, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -312,6 +312,15 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // room, which is correct for them: a ceremony held in its own NIP-17 room // ran over exactly that room's members and was named after it. AutoMigration(from = 18, to = 19), + // v20 adds the nullable ownerPublicKey to SynchronizeNostrEventRequest and + // NegentropySynchronizeRequest: the identity that asked, so that only its + // pump answers. The two queues used to be the device's, drained by whoever + // was open with their key pair, and a gift wrap fetched for one profile + // while another was open was stored under the wrong key and never opened + // again. Rows written before this read back null, meaning "the device's", + // which is what they were, and any identity may drain them. The broadcast + // queue deliberately gets no owner: a signed event is anyone's to carry. + AutoMigration(from = 19, to = 20), ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt index 19ff15f2..7c68fd5b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapMessageDao.kt @@ -9,6 +9,18 @@ interface GiftWrapMessageDao { @Query("SELECT * FROM GiftWrapMessage") suspend fun getAllGiftWrapMessages(): List + /** + * Every wrap addressed to [receiverPublicKey] that the device stored and never + * opened -- no seal names it. Fetched under another identity's key, or under a + * read-only identity's none; the inbox sweep at activation opens them. + */ + @Query( + "SELECT * FROM GiftWrapMessage WHERE receiverPublicKey = :receiverPublicKey " + + "AND id NOT IN (SELECT giftWrapMessageId FROM GiftWrapSeal WHERE giftWrapMessageId IS NOT NULL) " + + "ORDER BY createdAt ASC" + ) + suspend fun getUnopenedAddressedTo(receiverPublicKey: String): List + @Upsert suspend fun upsert(giftWrapMessage: press.mantra.compose.database.model.GiftWrapMessage) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt index a551c40a..a04771a8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapSealDao.kt @@ -9,6 +9,9 @@ interface GiftWrapSealDao { @Query("SELECT * FROM GiftWrapSeal") suspend fun getAllGiftWrapSeals(): List + @Query("SELECT COUNT(*) FROM GiftWrapSeal WHERE giftWrapMessageId = :giftWrapMessageId") + suspend fun countForMessage(giftWrapMessageId: String): Int + @Upsert suspend fun upsert(giftWrapSeal: press.mantra.compose.database.model.GiftWrapSeal) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt index 04298523..d4e2fa51 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NegentropySynchronizeRequestDao.kt @@ -14,6 +14,14 @@ interface NegentropySynchronizeRequestDao { @Query("SELECT * FROM NegentropySynchronizeRequest WHERE status = :status ORDER BY createdAt ASC, id ASC LIMIT 1") fun observeNegentropySynchronizeRequestsByStatus(status: String): Flow + /** The same head-of-queue, for one identity; see `SynchronizeNostrEventRequestDao.observeSynchronizeNostrEventRequestsByStatusFor`. */ + @Query( + "SELECT * FROM NegentropySynchronizeRequest " + + "WHERE status = :status AND (ownerPublicKey = :ownerPublicKey OR ownerPublicKey IS NULL) " + + "ORDER BY createdAt ASC, id ASC LIMIT 1" + ) + fun observeNegentropySynchronizeRequestsByStatusFor(status: String, ownerPublicKey: String): Flow + @Query("SELECT COUNT(*) FROM NegentropySynchronizeRequest WHERE purpose = :purpose AND status IN (:status)") fun observeNegentropySynchronizeRequestByPurposeAndStatusCount(purpose: String, status: List): Flow @@ -29,4 +37,7 @@ interface NegentropySynchronizeRequestDao { */ @Upsert suspend fun insert(negentropySynchronizeRequests: List) + + @Query("SELECT * FROM NegentropySynchronizeRequest") + suspend fun getAll(): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index f650955b..956d7fbd 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -31,6 +31,7 @@ import press.mantra.compose.exceptions.MarmotNotMemberOfChatGroupException import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.nostr.Relays import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.managers.FrostSigningManager @@ -146,6 +147,58 @@ abstract class NostrDao( } } + /** + * Opens every gift wrap addressed to [activeKeyPair] that the device stored and could + * not open at the time -- because another identity fetched it, or because this one + * held no key yet. Phase 6 of docs/multiple-profiles.md. + * + * [storeNostrEvent] never indexes an event it already holds, so a wrap that arrived + * under the wrong key would otherwise stay closed for good: the live subscription + * re-receives it, the DAO sees a known id, and returns. This runs the wrap through + * [indexNostrEvent] again, under the right key, one transaction per wrap so that a + * wrap that cannot be opened rolls back its own changes and the next is still tried. + * One pass: wraps do not depend on one another, so a wrap that cannot be opened now + * will not be helped by opening its neighbours. + * + * @return how many were opened. + */ + open suspend fun reopenInbox(activeKeyPair: KeyPair): Int { + if (activeKeyPair.privKey == null) return 0 + val me = activeKeyPair.pubKey.toHex() + val unopened = database.giftWrapMessageDao().getUnopenedAddressedTo(me) + if (unopened.isEmpty()) return 0 + logger.i("Reopening ${unopened.size} gift wrap(s) addressed to $me") + + var opened = 0 + for (wrap in unopened) { + val nostrEvent = database.nostrEventDao().getNostrEventById(wrap.nostrEventId) ?: continue + val relayURL = database.nostrEventRelayDao().getRelayUrls(nostrEvent.id).firstOrNull() + ?: Relays.DefaultDMRelayList.first().url + try { + reopenGiftWrap(nostrEvent, relayURL, activeKeyPair) + if (database.giftWrapSealDao().countForMessage(wrap.id) > 0) opened++ + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.w("Gift wrap ${wrap.id} could not be opened on the second try", e) + } + } + logger.i("Reopened $opened of ${unopened.size} gift wrap(s) for $me") + return opened + } + + /** One wrap, indexed again, in a transaction of its own; see [reopenInbox]. */ + @Transaction + open suspend fun reopenGiftWrap(nostrEvent: NostrEvent, relayURL: String, activeKeyPair: KeyPair) { + indexNostrEvent( + nostrEvent = nostrEvent, + relayURL = relayURL, + synchronizationRelayURLs = listOf(relayURL), + level = 0, + activeKeyPair = activeKeyPair, + ) + } + @Transaction open suspend fun storeNostrEvent( nostrEvent: NostrEvent, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt index 2078e514..a28dd942 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventRelayDao.kt @@ -1,6 +1,7 @@ package press.mantra.compose.database.dao import androidx.room3.Dao +import androidx.room3.Query import androidx.room3.Upsert @Dao @@ -9,4 +10,8 @@ interface NostrEventRelayDao { @Upsert suspend fun upsert(nostrEventRelay: press.mantra.compose.database.model.NostrEventRelay) + /** The relays an event was seen on, oldest first. */ + @Query("SELECT relayURL FROM NostrEventRelay WHERE nostrEventId = :nostrEventId ORDER BY createdAt ASC") + suspend fun getRelayUrls(nostrEventId: String): List + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt index 39f16323..d6645695 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/SynchronizeNostrEventRequestDao.kt @@ -14,6 +14,18 @@ interface SynchronizeNostrEventRequestDao { @Query("SELECT * FROM SynchronizeNostrEventRequest WHERE status = :status ORDER BY createdAt ASC, id ASC LIMIT 1") fun observeSynchronizeNostrEventRequestsByStatus(status: String): Flow + /** + * The same head-of-queue, for one identity: its own requests and the ones nobody + * owns. A request another identity queued waits for that identity, because what it + * brings back is opened with that identity's key. + */ + @Query( + "SELECT * FROM SynchronizeNostrEventRequest " + + "WHERE status = :status AND (ownerPublicKey = :ownerPublicKey OR ownerPublicKey IS NULL) " + + "ORDER BY createdAt ASC, id ASC LIMIT 1" + ) + fun observeSynchronizeNostrEventRequestsByStatusFor(status: String, ownerPublicKey: String): Flow + @Query("SELECT COUNT(*) FROM SynchronizeNostrEventRequest WHERE purpose = :purpose AND status IN (:status)") fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String, status: List): Flow @@ -31,4 +43,7 @@ interface SynchronizeNostrEventRequestDao { @Insert suspend fun insert(synchronizeNostrEventRequests: List) + + @Query("SELECT * FROM SynchronizeNostrEventRequest") + suspend fun getAll(): List } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt index a7b92840..abf3e438 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt @@ -137,7 +137,12 @@ data class GiftWrapMessage( content = giftWrapSeal.content, tags = giftWrapSeal.tags, createdAt = Instant.fromEpochSeconds(giftWrapSeal.createdAt), - signature = giftWrapSeal.sig + signature = giftWrapSeal.sig, + // The wrap this came out of. The column existed and was never + // written, so nothing could say which wraps had been opened; + // the inbox sweep asks exactly that. A seal from before this + // reads back null, is swept once, and comes back with the link. + giftWrapMessageId = id, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt index bf31361c..fd6934fc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NegentropySynchronizeRequest.kt @@ -42,6 +42,9 @@ data class NegentropySynchronizeRequest( val synchronizationFilter: press.mantra.compose.database.model.types.SynchronizationFilter, + /** The identity that asked; null for rows from before, which any identity may drain. See [SynchronizeNostrEventRequest.ownerPublicKey]. */ + val ownerPublicKey: HexKey? = null, + override val nostrEventId: HexKey? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, @@ -64,6 +67,7 @@ data class NegentropySynchronizeRequest( ), relayURL = relayURL, level = level, + ownerPublicKey = ownerPublicKey, ) } companion object { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt index 822a6e2b..9305efac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/SynchronizeNostrEventRequest.kt @@ -43,6 +43,13 @@ data class SynchronizeNostrEventRequest( val synchronizationFilters: Array, + /** + * The identity that asked, so that only its pump answers: what a fetch brings back + * is opened with the fetcher's key. Null for rows written before there was one to + * record, which any identity may drain. See docs/multiple-profiles.md, Phase 6. + */ + val ownerPublicKey: HexKey? = null, + override val nostrEventId: HexKey? = null, override val unsignedNostrEventId: Long? = null, override val createdAt: Instant = Clock.System.now(), @@ -61,6 +68,7 @@ data class SynchronizeNostrEventRequest( if (status != other.status) return false if (relayURL != other.relayURL) return false if (!synchronizationFilters.contentEquals(other.synchronizationFilters)) return false + if (ownerPublicKey != other.ownerPublicKey) return false if (nostrEventId != other.nostrEventId) return false if (createdAt != other.createdAt) return false if (updatedAt != other.updatedAt) return false @@ -74,6 +82,7 @@ data class SynchronizeNostrEventRequest( result = 31 * result + status.hashCode() result = 31 * result + relayURL.hashCode() result = 31 * result + synchronizationFilters.contentHashCode() + result = 31 * result + (ownerPublicKey?.hashCode() ?: 0) result = 31 * result + (nostrEventId?.hashCode() ?: 0) result = 31 * result + createdAt.hashCode() result = 31 * result + updatedAt.hashCode() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index 1d82481b..a68abcd8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -50,14 +50,25 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import press.mantra.compose.identity.Identity import kotlin.time.Clock import kotlin.time.Instant +/** + * @param activeIdentity whose requests the two fetch queues are stamped with. Every + * request queued through here is queued *as* the active identity -- a screen cannot + * queue anything as anyone else -- so the stamp is the repository's, not the call + * site's, and no call site can forget it. Defaults to nobody, which stamps nothing + * and is what a test that has no identity gets. + */ class DatabaseNostrRepository( private val database: press.mantra.compose.database.MantraDatabase, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val activeIdentity: StateFlow = MutableStateFlow(null), ): press.mantra.compose.repository.NostrRepository, press.mantra.compose.repository.RelayRepository { companion object { const val TAG = "DatabaseNostrRepository" @@ -108,12 +119,19 @@ class DatabaseNostrRepository( return database.broadcastNostrEventRequestDao().observeBroadcastNostrEventRequestsByStatus("pending") } - override suspend fun observePendingSynchronizeNostrEventRequests(): Flow { - return database.synchronizeNostrEventRequestDao().observeSynchronizeNostrEventRequestsByStatus("pending") + override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow { + return database.synchronizeNostrEventRequestDao().observeSynchronizeNostrEventRequestsByStatusFor("pending", ownerPublicKey) } - override suspend fun observePendingNegentropySynchronizeRequests(): Flow { - return database.negentropySynchronizeRequestDao().observeNegentropySynchronizeRequestsByStatus("pending") + override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow { + return database.negentropySynchronizeRequestDao().observeNegentropySynchronizeRequestsByStatusFor("pending", ownerPublicKey) + } + + override suspend fun reopenInbox(activeKeyPair: KeyPair): Int { + // Under the same lock as every other write through indexNostrEvent. + return storeNostrEventMutex.withLock { + database.nostrDao().reopenInbox(activeKeyPair) + } } override suspend fun createNewProfile( @@ -661,17 +679,23 @@ class DatabaseNostrRepository( } } + /** + * Stamped with the active identity where the caller stamped nothing. A request + * queued while nothing is active stays nobody's, which any identity may drain. + */ override suspend fun queueSynchronizeNostrEvent( synchronizeNostrEventRequests: List, ) { + val owner = activeIdentity.value?.nostrPublicKey database.synchronizeNostrEventRequestDao().insert( - synchronizeNostrEventRequests + synchronizeNostrEventRequests.map { it.copy(ownerPublicKey = it.ownerPublicKey ?: owner) } ) } override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { + val owner = activeIdentity.value?.nostrPublicKey database.negentropySynchronizeRequestDao().insert( - negentropySynchronizeRequests + negentropySynchronizeRequests.map { it.copy(ownerPublicKey = it.ownerPublicKey ?: owner) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 2ea083e5..4e8a9805 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -45,9 +45,17 @@ interface NostrRepository { suspend fun observePendingBroadcastNostrEventRequests(): Flow - suspend fun observePendingSynchronizeNostrEventRequests(): Flow + /** The head of the fetch queue for one identity: its own requests and the ones nobody owns. */ + suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow - suspend fun observePendingNegentropySynchronizeRequests(): Flow + suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow + + /** + * Opens every gift wrap addressed to [activeKeyPair] that the device stored and could + * not open at the time. Run once per activation, before the live inbox is opened. + * Returns how many were opened. + */ + suspend fun reopenInbox(activeKeyPair: KeyPair): Int suspend fun createNewProfile( publicKey: HexKey, @@ -237,11 +245,13 @@ interface NostrRepository { TODO("Not yet implemented") } - override suspend fun observePendingSynchronizeNostrEventRequests(): Flow { + override suspend fun reopenInbox(activeKeyPair: KeyPair): Int = 0 + + override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow { TODO("Not yet implemented") } - override suspend fun observePendingNegentropySynchronizeRequests(): Flow { + override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index ad9e3731..1ee9ae8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -177,10 +177,19 @@ fun MantraNavHost( AuxDatabaseManager(mantraGlobal) } + // Before the repositories: the nostr repository stamps every fetch request it queues + // with the active identity, and takes the flow at construction. + val sovereignWalletViewModel: SovereignWalletViewModel = viewModel( + factory = SovereignWalletViewModel.factory( + phoenixGlobal = phoenixGlobal + ) + ) + val databaseNostrRepository = remember { DatabaseNostrRepository( database = auxDatabaseManager.auxDatabase, - applicationIOScope + applicationIOScope, + activeIdentity = sovereignWalletViewModel.activeIdentity, ) } @@ -224,12 +233,6 @@ fun MantraNavHost( ) } - val sovereignWalletViewModel: SovereignWalletViewModel = viewModel( - factory = SovereignWalletViewModel.factory( - phoenixGlobal = phoenixGlobal - ) - ) - val navigationViewModel: NavigationViewModel = viewModel ( factory = NavigationViewModel.factory( activeIdentity = sovereignWalletViewModel.activeIdentity, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt index 63dcd40a..ef1e7c8b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt @@ -22,6 +22,7 @@ import press.mantra.compose.repository.RelayRepository import co.touchlab.kermit.Logger import com.vitorpamplona.negentropy.Negentropy import com.vitorpamplona.negentropy.storage.StorageVector +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd @@ -227,6 +228,14 @@ class SynchronizationViewModel( // things this identity cannot open. Not asking is not an // optimisation; it is the identity not asking for what it cannot use. if (identity.canSign) { + // The wraps that arrived while another profile was open, or + // while this one held no key: opened now, before the live inbox + // starts, so the two are not opening the same wrap at once. Both + // are idempotent by event id; the order is about not doing the + // work twice. See docs/multiple-profiles.md, Phase 6. + runCatching { nostrRepository.reopenInbox(keyPair) } + .onSuccess { if (it > 0) logger.i { "Reopened $it gift wrap(s) that arrived while this profile was not open" } } + .onFailure { logger.e("Could not reopen the inbox", it) } launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) } // Runs until cancelled rather than draining a queue, but it // belongs here for the same reason the pumps do: it needs the @@ -245,7 +254,8 @@ class SynchronizationViewModel( ): Unit = coroutineScope { logger.i { "observePendingSyncNostrEventRequests" } - nostrRepository.observePendingSynchronizeNostrEventRequests().distinctUntilChanged().collect { synchronizeNostrEventRequestOrNull -> + // This identity's requests and the ones nobody owns; another identity's wait for it. + nostrRepository.observePendingSynchronizeNostrEventRequests(keyPair.pubKey.toHexKey()).distinctUntilChanged().collect { synchronizeNostrEventRequestOrNull -> synchronizeNostrEventRequestOrNull?.let { synchronizeNostrEventRequest -> guardPump("sync request ${synchronizeNostrEventRequest.id}") { logger.i("synchronizeNostrEventRequest: $synchronizeNostrEventRequest") @@ -406,7 +416,7 @@ class SynchronizationViewModel( ): Unit = coroutineScope { logger.i { "observePendingNegentropySynchronizeRequests" } - nostrRepository.observePendingNegentropySynchronizeRequests().distinctUntilChanged().collect { negentropySynchronizeRequestOrNull -> + nostrRepository.observePendingNegentropySynchronizeRequests(keyPair.pubKey.toHexKey()).distinctUntilChanged().collect { negentropySynchronizeRequestOrNull -> negentropySynchronizeRequestOrNull?.let { negentropySynchronizeRequest -> guardPump("negentropy request ${negentropySynchronizeRequest.id}") { mutex.withLock { diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/OwnedRequestQueueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/OwnedRequestQueueJvmTest.kt new file mode 100644 index 00000000..2f545adf --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/OwnedRequestQueueJvmTest.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.database.dao + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room3.Room +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.utils.preferences.InternalPrefs +import fr.acinq.phoenix.utils.preferences.UserPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toPath +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.identity.Identity +import press.mantra.compose.identity.IdentityKind +import press.mantra.compose.identity.toWalletId +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * The two fetch queues, owned -- Phase 6 of docs/multiple-profiles.md. + * + * A request another identity queued waits for that identity, because what it brings + * back is opened with that identity's key; a request nobody owns -- one from before the + * column, or queued while nothing was open -- is anyone's. And the owner is the + * repository's to stamp, from the identity that is open when the request is queued, + * so that no call site can forget it. + */ +class OwnedRequestQueueJvmTest { + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + + private val keyA = PrivateKey(ByteVector32("0a".repeat(32))) + private val keyB = PrivateKey(ByteVector32("0b".repeat(32))) + private val a = keyA.nostrPublicKeyHex() + private val b = keyB.nostrPublicKeyHex() + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + } + + private fun sync(id: String, owner: String?, createdAt: Instant = at) = SynchronizeNostrEventRequest( + id = id, + purpose = "test", + relayURL = "wss://relay.example", + level = 0, + synchronizationFilters = arrayOf(SynchronizationFilter(kinds = arrayOf(1))), + ownerPublicKey = owner, + createdAt = createdAt, + updatedAt = createdAt, + ) + + private fun negentropy(id: String, owner: String?, createdAt: Instant = at) = NegentropySynchronizeRequest( + id = id, + purpose = "test", + relayURL = "wss://relay.example", + level = 0, + synchronizationFilter = SynchronizationFilter(kinds = arrayOf(1)), + ownerPublicKey = owner, + createdAt = createdAt, + updatedAt = createdAt, + ) + + @Test + fun `a sync request is offered to its owner and to nobody else, and an unowned one to everyone`() = runBlocking { + val dao = db.synchronizeNostrEventRequestDao() + dao.insert(listOf(sync("a-1", owner = a))) + + assertEquals("a-1", dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", a).first()?.id) + assertNull(dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", b).first(), "A's request is not B's to answer") + + dao.insert(listOf(sync("anyone", owner = null, createdAt = at.plus(kotlin.time.Duration.parse("1s"))))) + assertEquals("a-1", dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", a).first()?.id, "oldest first, still") + assertEquals("anyone", dao.observeSynchronizeNostrEventRequestsByStatusFor("pending", b).first()?.id, "nobody's is anyone's") + } + + @Test + fun `a negentropy request is offered the same way`() = runBlocking { + val dao = db.negentropySynchronizeRequestDao() + dao.insert(listOf(negentropy("a-1", owner = a), negentropy("anyone", owner = null, createdAt = at.plus(kotlin.time.Duration.parse("1s"))))) + + assertEquals("a-1", dao.observeNegentropySynchronizeRequestsByStatusFor("pending", a).first()?.id) + assertEquals("anyone", dao.observeNegentropySynchronizeRequestsByStatusFor("pending", b).first()?.id) + } + + private fun prefsFile(name: String) = + Files.createTempFile(name, ".preferences_pb").also { it.toFile().delete() }.toString().toPath() + + private fun identity(key: PrivateKey) = Identity.signing( + id = key.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPrivateKey = key, + userPrefs = UserPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("user") }), + internalPrefs = InternalPrefs(PreferenceDataStoreFactory.createWithPath { prefsFile("internal") }), + business = null, + ) + + @Test + fun `the repository stamps what it queues with the identity that is open, and nothing when none is`() = runBlocking { + val active = MutableStateFlow(null) + val repository = DatabaseNostrRepository(db, scope, activeIdentity = active) + + repository.queueSynchronizeNostrEvent(listOf(sync("nobody", owner = null))) + repository.queueNegentropySynchronizeRequest(listOf(negentropy("nobody-n", owner = null))) + + active.value = identity(keyA) + repository.queueSynchronizeNostrEvent(listOf(sync("as-a", owner = null))) + repository.queueNegentropySynchronizeRequest(listOf(negentropy("as-a-n", owner = null))) + // A caller that named an owner keeps it. + repository.queueSynchronizeNostrEvent(listOf(sync("named-b", owner = b))) + + val syncs = db.synchronizeNostrEventRequestDao().getAll().associate { it.id to it.ownerPublicKey } + assertEquals(mapOf("nobody" to null, "as-a" to a, "named-b" to b), syncs) + val negs = db.negentropySynchronizeRequestDao().getAll().associate { it.id to it.ownerPublicKey } + assertEquals(mapOf("nobody-n" to null, "as-a-n" to a), negs) + + // A negentropy request carries its owner into the REQ it becomes. + assertEquals(a, negentropy("x", owner = a).toSynchronizeNostrEventRequest().ownerPublicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt index 7a4478fc..fe21ab2b 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/ReadOnlyGiftWrapDaoJvmTest.kt @@ -17,7 +17,9 @@ import kotlin.test.assertNotNull import kotlin.time.Instant /** - * A gift wrap addressed to a read-only identity is kept and not opened. + * A gift wrap addressed to a read-only identity is kept and not opened -- and, since + * docs/multiple-profiles.md, opened later by the inbox sweep, along with any wrap that + * arrived while another profile on the device was the open one. * * `storeNostrEvent` is `@Transaction` and indexes inside it, and a wrap addressed to the * active key that cannot be unsealed throws -- so before the guard in `indexNostrEvent` @@ -103,4 +105,63 @@ class ReadOnlyGiftWrapDaoJvmTest { assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) } + + // --- The inbox, reopened: Phase 6 of docs/multiple-profiles.md --- + + /** Another profile on the device, open when the wrap for us arrived. */ + private val other = KeyPair() + + /** + * The case the sweep exists for. A wrap for us, fetched while another profile was + * open: stored, found not to be addressed to the active key, and left -- and + * `storeNostrEvent` never indexes an event it already holds, so the live subscription + * re-delivering it later would change nothing. The sweep runs it through the index + * again under our key. + */ + @Test + fun `a wrap fetched under another profile's key is opened by the sweep, once`() = runBlocking { + val wrap = wrapTo(us) + db.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = other, + ) + assertEquals(listOf(wrap.id), db.giftWrapMessageDao().getAllGiftWrapMessages().map { it.id }, "stored") + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals(), "and not opened") + + // Re-delivery changes nothing: the id is known, and the DAO returns. + db.nostrDao().storeNostrEvent(wrap, relay, listOf(relay), 0, us) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals(), "re-delivery does not reopen a known event") + + assertEquals(1, db.nostrDao().reopenInbox(us)) + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size, "the seal is out") + assertEquals(1, db.giftWrapPayloadDao().getAllGiftWrapUnsigned().size, "and the payload with it") + + assertEquals(0, db.nostrDao().reopenInbox(us), "nothing left to open") + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) + } + + /** The other profile's sweep finds nothing of ours to open, and a read-only pair opens nothing. */ + @Test + fun `the sweep opens only what is addressed to the key it runs with`() = runBlocking { + val wrap = wrapTo(us) + db.nostrDao().storeNostrEvent(wrap, relay, listOf(relay), 0, other) + + assertEquals(0, db.nostrDao().reopenInbox(other)) + assertEquals(0, db.nostrDao().reopenInbox(readOnlyUs)) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals()) + } + + /** The upgrade the npub plan left open: stored read-only, opened once the nsec is here. */ + @Test + fun `a wrap stored while the identity was read-only is opened once it holds its key`() = runBlocking { + val wrap = wrapTo(us) + db.nostrDao().storeNostrEvent(wrap, relay, listOf(relay), 0, readOnlyUs) + assertEquals(emptyList(), db.giftWrapSealDao().getAllGiftWrapSeals()) + + assertEquals(1, db.nostrDao().reopenInbox(us)) + assertEquals(1, db.giftWrapSealDao().getAllGiftWrapSeals().size) + } } From b92659fe92218c74c8e541aab83a46835fa94950 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 15:54:28 +0200 Subject: [PATCH 51/71] docs: record Phase 4 of the profiles pull, the migration to version 20 759199f2 landed as 72afe8aa, a clean pick, and with it the database's first migration since the fork: version 20, two nullable owner columns on the fetch queues by AutoMigration, the export committed as composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json. The check this phase exists for passed: after a full build of both targets, git status shows nothing under composeApp/schemas/, and the committed 20.json is byte-identical to the rewritten fork's, identity hash 4414c373. Room derived the same twenty tables from Mantra's entities as from Curare's, which is the second decision's rule -- whichever tree migrates first owns the number, the other pulls before it adds its own -- holding on its first use. Exactness unchanged at 135 residual files with nothing moved. Both compilers clean, jvmTest 858 -> 864, testDebugUnitTest 420, every audit budget met. Read in review rather than assumed: the inbox sweep runs inside the canSign branch before the broadcast pump and the live subscriptions launch, returns zero without a query for a key pair that holds no private key, re-indexes each wrap in a transaction of its own and logs rather than throws when one cannot be opened; the DAO's own placeholder and participant syncs carry no owner; the seal now links to the wrap it came out of; and the migration's comment says why the broadcast queue was given no owner column. Co-Authored-By: Claude Opus 5 --- docs/curated-to-mantra-profiles.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md index 72cdd26a..a6062dc1 100644 --- a/docs/curated-to-mantra-profiles.md +++ b/docs/curated-to-mantra-profiles.md @@ -462,8 +462,21 @@ full compile, then `git status` — nothing under `composeApp/schemas/`. Exit: `OwnedRequestQueueJvmTest` and `ReadOnlyGiftWrapDaoJvmTest` green; an npub signed in, sent a DM by someone else, then upgraded with its nsec reads the DM on the -next activation; -schema files 1–20 committed and regenerated identically. +next activation; schema files 1–20 committed and regenerated identically. + +**Built 2026-09-13**, `72afe8aa`, a clean pick. Exactness unchanged at 135, nothing +moved. Both compilers clean; `jvmTest` 858 → 864 (`OwnedRequestQueueJvmTest` and the +sweep cases added to `ReadOnlyGiftWrapDaoJvmTest`); `testDebugUnitTest` 420; audit all +budgets met. The schema check, which is what this phase is for: after a full build of +both targets `git status` shows nothing under `composeApp/schemas/`, and the committed +`20.json` is byte-identical to the rewritten fork's, identity hash `4414c373…` — Room +computed the same twenty tables from Mantra's entities as from Curare's, which is the +second decision's rule holding on its first use. Read in review: the sweep runs inside +`identity.canSign`, before the broadcast pump and the live subscriptions are launched; +it returns 0 without a query for a key pair with no private key; each wrap is re-indexed +in its own transaction and a failure is logged and skipped rather than thrown; the DAO's +own requests carry no owner; `GiftWrapMessage.toGiftWrapSeal` sets `giftWrapMessageId`; +the migration's comment says why the broadcast queue has no owner column. ### Phase 5 — the exits, the round trip, the record From 8edb52fff30a7e94074d7c30d7d5de824a919f9d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 01:44:44 +0200 Subject: [PATCH 52/71] feat(ui): leaving one profile among several Phase 7 of docs/multiple-profiles.md. Small, because the forget sequence was built right. Sign out of a read-only identity and "forget this key" for a bare key both run ForgetIdentity and then the tail -- re-list, clear the default since Phase 3, show the selector with what remains or Landing if nothing does -- and that is the right behaviour with several profiles as well; nothing in the sequence changes. A profile with a wallet attached answers WalletAttached from Phase 1 and is offered neither exit: its sign-out row stays the pending route, for the reason both sign-in plans gave, and it is now also the only way such a profile can leave, since the repair would write its credential back. What changes is that the user is no longer stuck behind it -- "switch profile" is the row above. The not-found screen learns about the others. It offers "try again" and, for a read-only identity, "use a different key", which forgets it; with another profile on the device it now offers "switch profile" as well, for every kind, pushing the switcher -- which has a back button, so the screen is still there if the user changes their mind. The identity that was not found stays listed; forgetting it remains a separate decision. The nav host passes the callback only when the listing holds more than one. Tests: ReadOnlyEntrancesJvmTest -- with another profile on the device the not-found state offers the switch beside the read-only exit and it calls through once; a signing identity that was not found gets it above the set-up form; with one profile there is nothing to switch to. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@9bb340042d15ee40c02c64fc3a0ba907ae8452d0 --- .../ui/composable/UnsyncedProfileScreen.kt | 21 ++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 7 ++++ .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 34 ++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 69c81a63..bb9c4025 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -43,6 +43,7 @@ import mantra.composeapp.generated.resources.set_up_a_profile import mantra.composeapp.generated.resources.try_again import mantra.composeapp.generated.resources.use_a_different_key import mantra.composeapp.generated.resources.use_a_different_key_question +import mantra.composeapp.generated.resources.switch_profile import mantra.composeapp.generated.resources.could_not_sign_out_please_try_again import press.mantra.compose.ui.composable.widgets.dialogs.SignOutOfReadOnlyDialog import press.mantra.compose.ui.composable.widgets.rememberNotifier @@ -82,6 +83,10 @@ fun UnsyncedProfileScreen( // act as signing out, reached from the other end of the identity's life. Null where // there is no identity to leave (previews), in which case that kind has no exit here. signOut: SignOutDependencies? = null, + // The third exit, for every kind: another profile on the device to go to instead. + // Null when there is none, in which case the row is not drawn. The identity that was + // not found stays listed; forgetting it remains a separate decision. + onSwitchProfile: (() -> Unit)? = null, ) { val viewModel: UnsyncedProfileViewModel = viewModel( factory = UnsyncedProfileViewModel.factory( @@ -132,6 +137,7 @@ fun UnsyncedProfileScreen( viewModel = viewModel, unsignedNostrEventId = state.unsignedNostrEventId, onUseADifferentKey = signOutViewModel?.let { { it.askToSignOut() } }, + onSwitchProfile = onSwitchProfile, ) } } @@ -190,6 +196,8 @@ private fun NotFound( unsignedNostrEventId: Long, /** Shown only where the set-up form is not; null when there is no identity to leave. */ onUseADifferentKey: (() -> Unit)?, + /** Shown for every kind, when there is another profile to go to. */ + onSwitchProfile: (() -> Unit)?, ) { val nameField = viewModel.formState.nameField.textFieldState val biographyField = viewModel.formState.biographyField.textFieldState @@ -222,6 +230,19 @@ private fun NotFound( style = MaterialTheme.typography.bodyMedium, ) + // With other profiles on the device, a way to one of them -- for every kind, since + // the wait is the same whichever kind is waiting. See docs/multiple-profiles.md, + // Phase 7. + if (onSwitchProfile != null) { + TextButton( + modifier = Modifier.align(Alignment.End), + enabled = !isActionPending, + onClick = onSwitchProfile, + ) { + Text(text = stringResource(Res.string.switch_profile)) + } + } + // Setting a profile up is signing a kind 0, which a read-only identity cannot do. // The form is not offered to it -- and then this state would have no exit: the // profile tab is not reachable before ProfileLoaded, so a user whose npub was not diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 1ee9ae8e..dfd6fda4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -757,10 +758,16 @@ fun MantraNavHost( } composable { backStackEntry -> val route = backStackEntry.toRoute() + val identities by sovereignWalletViewModel.availableIdentities.collectAsState() UnsyncedProfileScreen( unsyncedProfilePublicKey = route.publicKey, nostrRepository = databaseNostrRepository, signOut = signOutOfReadOnlyIdentity, + // Another profile to go to, when there is one; the switcher has a back + // button, so this screen is still there if the user changes their mind. + onSwitchProfile = if (identities.size > 1) { + { navController.navigate(route = ProfilesRoute) } + } else null, ) } composable { backStackEntry -> diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt index eb85a62f..5239ebb5 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -45,6 +45,7 @@ import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.ActiveProfileUIState import press.mantra.compose.ui.view.state.HomeScreenUIState import kotlin.test.Test +import kotlin.test.assertEquals /** * The inventory in Phase 5 of docs/npub-sign-in.md, as assertions. @@ -154,6 +155,36 @@ class ReadOnlyEntrancesJvmTest { onNodeWithText("Use a different key?", useUnmergedTree = true).assertIsDisplayed() } + // --- Not found: the third exit, when there is another profile to go to --- + + @Test + fun `with another profile on the device the not-found state offers to switch, for either kind`() = runDesktopComposeUiTest(400, 1200) { + var switched = 0 + setContent { AsIdentity(canSign = false) { NotFound(onSwitchProfile = { switched++ }) } } + + onNodeWithText("Switch profile", useUnmergedTree = true).performClick() + assertEquals(1, switched) + // The read-only exit is still there beside it; switching does not forget. + onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a signing identity that was not found is offered the switch above the set-up form`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { NotFound(onSwitchProfile = {}) } } + + onNodeWithText("Switch profile", useUnmergedTree = true).assertIsDisplayed() + // The heading and the button both say it; either is enough to know the form is there. + onAllNodesWithText("Set up a profile", useUnmergedTree = true).onFirst().assertIsDisplayed() + } + + @Test + fun `with one profile on the device there is nothing to switch to`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { NotFound(onSwitchProfile = null) } } + + onNodeWithText("Use a different key", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Switch profile", useUnmergedTree = true).assertDoesNotExist() + } + // --- harness --- private val readOnlyIdentity = MutableStateFlow( @@ -174,11 +205,12 @@ class ReadOnlyEntrancesJvmTest { ) @Composable - private fun NotFound() { + private fun NotFound(onSwitchProfile: (() -> Unit)? = null) { UnsyncedProfileScreen( unsyncedProfilePublicKey = publicKey, nostrRepository = NotFoundAccount, signOut = signOutDependencies, + onSwitchProfile = onSwitchProfile, ) } From 72b9d3d2ed78531bc91d350007e4b81e045d426f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 01:57:05 +0200 Subject: [PATCH 53/71] test(identity): several profiles, both ways, and a boot Phase 8 of docs/multiple-profiles.md: the seams of Phases 1 through 7 in a row, through the real view models on an in-memory database with a real key store. A is made from a phrase through the create view model and the seed writer, the way Landing makes the first profile: both files written, and listed once as a profile with a wallet attached, under the wallet's id, with the credential's key. A is opened with a node behind it -- a PhoenixBusiness that was never started, which is enough to make it one the switch has to stop. B's npub is signed in from inside through the sign-in view model; the switch stops A's node, B is open and read-only, the machine routes it from its placeholder, and nothing was ever started for a public key. A kind 1 queued for A while B is open waits three seconds unsigned; back to A, and it goes out, with nothing more stopped since B had no node. C is created from inside through the bare-key writer: a key, not a second wallet, routed, with no node, and the switch to it stops A's node again. C and B are forgotten through ForgetIdentity with the view model's writers, the default is cleared and the selector reset: A is listed alone, its credential is the only entry left in the file, B's and C's account rows went with them, and A itself cannot be forgotten because a wallet is attached. Then a fresh view model over the same directory, which is what a cold boot is: the repair finds nothing to do, A is listed from its credential rather than derived, and StartupChoice opens it without asking. A navigation state is matched to a profile by whichever step it is at -- UnsignedProfile, UnqueuedProfileSynchronization, UnannouncedProfile through the event the broadcast request names, ProfileLoaded -- because with the notary running, A's account moves from unsigned to announced-pending while the test looks, and what the test asserts is whose account it is, not which step it reached. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@88577bb5c3082e1765d2d33ada1271a947f245f1 --- .../MultipleProfilesRoundTripJvmTest.kt | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt new file mode 100644 index 00000000..1669f3ba --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/MultipleProfilesRoundTripJvmTest.kt @@ -0,0 +1,282 @@ +package press.mantra.compose.identity + +import androidx.room3.Room +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.lightning.Lightning +import fr.acinq.phoenix.PhoenixBusiness +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.data.EmptyWalletId +import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.jvm.BusinessManager +import fr.acinq.phoenix.managers.DataStoreManager +import fr.acinq.phoenix.managers.NodeParamsManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential +import fr.acinq.phoenix.utils.PlatformContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.UnsignedNostrEvent +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMarmotRepository +import press.mantra.compose.database.repository.DatabaseNostrRepository +import press.mantra.compose.extensions.getGlobalPrefs +import press.mantra.compose.ui.view.model.CreateProfileViewModel +import press.mantra.compose.ui.view.model.NavigationViewModel +import press.mantra.compose.ui.view.model.NotaryViewModel +import press.mantra.compose.ui.view.model.SignInToProfileViewModel +import press.mantra.compose.ui.view.model.SovereignWalletViewModel +import press.mantra.compose.ui.view.model.WritingSeedState +import press.mantra.compose.ui.view.model.bareKeyProfileWriter +import press.mantra.compose.ui.view.model.seedProfileWriter +import press.mantra.compose.ui.view.state.CreateProfileUIState +import press.mantra.compose.ui.view.state.NavigationUIState +import press.mantra.compose.ui.view.state.StartupChoice +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock + +/** + * Several profiles, both ways, and a boot -- Phase 8 of docs/multiple-profiles.md, the + * seams of Phases 1 through 7 in a row, through the real view models on an in-memory + * database with a real key store. + * + * A is made from a phrase, the way Landing makes the first profile: both files written, + * listed once with a wallet attached. A is opened with a node behind it -- a + * PhoenixBusiness that was never started, which is enough to make it one the switch + * has to stop. B's npub is signed in from inside; the switch stops A's node, B is open + * and read-only, and the machine routes it from its placeholder. A kind 1 queued for A + * while B is open waits, and goes out when A is back. C is created from inside as a bare + * key, with no node. B and C are forgotten; one profile remains, the default is cleared, + * and a fresh view model over the same directory -- what a cold boot is -- lists A alone, + * with its credential still the only entry in the file, and opens it without asking. + */ +class MultipleProfilesRoundTripJvmTest { + + private lateinit var storeDir: File + private lateinit var appDir: File + private lateinit var phoenixGlobal: PhoenixGlobal + + private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val nostrRepository = DatabaseNostrRepository(db, scope) + private val chatRepository = DatabaseChatRepository(db, scope) + private val marmotRepository = DatabaseMarmotRepository(db, scope) + + private val keyB = PrivateKey(Lightning.randomBytes(32)) + private val stopped = mutableListOf() + + @BeforeTest + fun setUp() { + storeDir = Files.createTempDirectory("mantra-profiles-store").toFile() + appDir = Files.createTempDirectory("mantra-profiles-app").toFile() + JvmKeyStore.lock() + JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir) + // No GlobalPrefs of the test's own -- see AddProfileFromInsideJvmTest.setUp. + phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir)) + } + + @AfterTest + fun tearDown() { + scope.cancel() + db.close() + JvmKeyStore.lock() + storeDir.deleteRecursively() + appDir.deleteRecursively() + } + + private val dataStoreManager by lazy { DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) } + + private suspend fun listed(sovereign: SovereignWalletViewModel, size: Int): Map { + val done = CompletableDeferred() + sovereign.listIdentities { done.complete(Unit) } + withTimeout(15_000) { done.await() } + return sovereign.availableIdentities.first { it.size == size } + } + + /** What the create screen does with a writer, minus the screen. */ + private suspend fun create(writer: NewProfileWriter, name: String): WalletId { + val created = CompletableDeferred() + val viewModel = CreateProfileViewModel( + initialCreateProfileUIState = CreateProfileUIState.ConfirmInput(name = name, bio = "round trip"), + nostrRepository = nostrRepository, + marmotRepository = marmotRepository, + ) + viewModel.createProfileFormState.nameField.textFieldState.edit { append(name) } + viewModel.createProfileFormState.biographyField.textFieldState.edit { append("round trip") } + viewModel.createAccount(writer) { created.complete(it) } + return withTimeout(15_000) { created.await() } + } + + /** What startup does for each kind, minus the node: a wallet-attached profile gets a business it can stop. */ + private fun activated(stored: StoredIdentity): Identity = when (stored) { + is StoredIdentity.Mnemonic -> Identity.signing( + id = stored.id, kind = IdentityKind.Mnemonic, nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = PhoenixBusiness(phoenixGlobal), + ) + is StoredIdentity.NostrSecret -> Identity.signing( + id = stored.id, kind = IdentityKind.NostrSecret, nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + is StoredIdentity.NostrPublic -> Identity.readOnly( + id = stored.id, nostrPublicKey = stored.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + ) + } + + /** Whether a navigation state is about [publicKey]'s account, whichever step it is at. */ + private suspend fun NavigationUIState.isFor(publicKey: String): Boolean = when (this) { + is NavigationUIState.UnsignedProfile -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnqueuedProfileSynchronization -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnsyncedProfile -> unsignedNostrEvent.pubKey == publicKey + is NavigationUIState.UnannouncedProfile -> db.nostrEventDao().getNostrEventById(broadcastNostrEventRequest.nostrEventId)?.pubKey == publicKey + is NavigationUIState.ProfileLoaded -> this.publicKey == publicKey + else -> false + } + + private suspend fun NavigationViewModel.awaitRoutedTo(publicKey: String): NavigationUIState = withTimeout(20_000) { + navigationUIState.first { it.isFor(publicKey) } + } + + private suspend fun queueNote(publicKey: String, content: String) { + val now = Clock.System.now() + db.unsignedNostrEventDao().upsert( + UnsignedNostrEvent(pubKey = publicKey, kind = 1, tags = emptyArray(), content = content, createdAt = now, updatedAt = now, savedAt = now) + ) + } + + private suspend fun notesQueuedFor(publicKey: String) = + nostrRepository.observeUnsignedNostrEvents(publicKey).first().filter { it.kind == 1 } + + @Test + fun `several profiles, both ways, and a boot`() = runBlocking { + val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val navigation = NavigationViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + initialNavigationUIState = NavigationUIState.Loading(""), + nostrRepository = nostrRepository, + scope = scope, + ) + NotaryViewModel( + activeIdentityStateFlow = sovereign.activeIdentity, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + marmotRepository = marmotRepository, + scope = scope, + ) + val signIn = SignInToProfileViewModel( + nostrRepository = nostrRepository, + writeNostrKey = { key -> sovereign.writeNostrKey(key) }, + writeNostrPublicKey = { pubkey -> sovereign.writeNostrPublicKey(pubkey) }, + writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, + ) + + // A, from a phrase: both files written, listed once with a wallet attached. + val idA = create(sovereign.seedProfileWriter(), "Ada") + val storedA = assertIs(listed(sovereign, 1)[idA], "a profile with a wallet attached, under the wallet's id") + val credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + assertEquals(mapOf(storedA.nostrPublicKey to NostrCredential.Secret(storedA.privateKey)), credentials, "the seed's key is a credential") + sovereign.setActiveIdentity(activated(storedA)) + navigation.awaitRoutedTo(storedA.nostrPublicKey) + + // B, an npub, signed in from inside: the switch stops A's node; B is open, read-only, and routed. + val signedInB = assertIs( + signIn.commit(SignInCredential.NostrPublicKey(keyB.nostrPublicKeyHex())) + ) + val storedB = assertIs(listed(sovereign, 2)[signedInB.id]) + sovereign.switchToIdentity(signedInB.id) + withTimeout(5_000) { while (stopped.isEmpty()) delay(20) } + assertEquals(listOf(idA), stopped, "leaving A stopped A's node") + sovereign.setActiveIdentity(activated(storedB)) + assertIs(navigation.awaitRoutedTo(storedB.nostrPublicKey)) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "nothing was ever started for a public key") + + // What A queues while B is open waits for A. + queueNote(storedA.nostrPublicKey, "while B is open") + delay(3_000) + assertEquals(1, notesQueuedFor(storedA.nostrPublicKey).size, "not B's to sign") + + // Back to A: routed, and the note goes out. B had no node, so nothing more was stopped. + sovereign.switchToIdentity(idA) + sovereign.setActiveIdentity(activated(storedA)) + navigation.awaitRoutedTo(storedA.nostrPublicKey) + withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(storedA.nostrPublicKey).first { rows -> rows.none { it.kind == 1 } } } + assertEquals(listOf(idA), stopped) + + // C, created from inside: a bare key, no node, and the switch stops A's node again. + val idC = create(sovereign.bareKeyProfileWriter(), "Cy") + val storedC = assertIs(listed(sovereign, 3)[idC], "a key, not a second wallet") + sovereign.switchToIdentity(idC) + withTimeout(5_000) { while (stopped.size < 2) delay(20) } + assertEquals(listOf(idA, idA), stopped) + sovereign.setActiveIdentity(activated(storedC)) + navigation.awaitRoutedTo(storedC.nostrPublicKey) + assertTrue(BusinessManager.businessFlow.value.isEmpty(), "no node for a bare key") + + // Forget C, the one that is open, then B: the forget tail, minus the navigation. + for (leaving in listOf(activated(storedC), activated(storedB))) { + val outcome = ForgetIdentity.forget( + identity = leaving, + nostrRepository = nostrRepository, + forgetNostrCredential = { sovereign.forgetNostrCredential(it) }, + hideIdentityMetadata = { sovereign.hideIdentityMetadata(it) }, + ) + assertEquals(ForgetIdentity.Outcome.Forgotten, outcome) + } + sovereign.forgetDefaultIdentity() + sovereign.resetToSelector() + assertNull(sovereign.activeIdentity.value) + assertEquals(setOf(idA), listed(sovereign, 1).keys) + assertEquals( + mapOf(storedA.nostrPublicKey to NostrCredential.Secret(storedA.privateKey)), + NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal), + "A's credential is the only entry left", + ) + assertEquals(1, nostrRepository.getLocalAccounts().size, "B's and C's account rows went with them") + val prefs = getGlobalPrefs(phoenixGlobal) + withTimeout(5_000) { prefs.getDefaultWallet.first { it == EmptyWalletId } } + + // And A cannot be forgotten: a wallet is attached. + assertEquals( + ForgetIdentity.Outcome.WalletAttached, + ForgetIdentity.forget(activated(storedA), nostrRepository, { sovereign.forgetNostrCredential(it) }, { sovereign.hideIdentityMetadata(it) }), + ) + + // A cold boot: a fresh view model over the same directory. The repair finds nothing + // to do, A is listed alone, and startup opens it without asking. + val booted = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it }) + val identities = withTimeout(15_000) { booted.availableIdentities.first { it.isNotEmpty() } } + assertEquals(setOf(idA), identities.keys) + assertEquals(storedA.privateKey, assertIs(identities[idA]).privateKey, "listed from the credential, not derived") + val opens = StartupChoice.resolve( + force = null, + desired = null, + startImmediately = true, + identities = identities, + default = prefs.getDefaultWallet.first() as? WalletId, + ) + assertEquals(idA, opens?.id) + } +} From ba5ef723853ae8887b07ceb7e289c97808f72314 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 01:57:05 +0200 Subject: [PATCH 54/71] docs: record what the multiple-profiles plan built, and the places it chose differently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 1–8 are implemented, in order, one commit each; Phase 9 is the rollout and stays as written. The plan's phases are kept as the reasoning, and the table at the top says where the build chose differently: the repair reads before it writes and hands the listing its result; one WalletAttached outcome with two ways in; the node stop and the relay scope injected for their tests; the default save that cannot crash; a ProfilesViewModel over flows; a NewProfileWriter and a route flag where the plan expected the create screen's existing writer to serve; the colliding id on the outcome rather than on the enum; the DAO's own requests left unowned because they fetch public kinds; the inbox reopened by re-indexing each wrap in its own transaction rather than by lifting the unseal branch out; and the round trip's A made through the create view model with a never-started PhoenixBusiness for the switch to stop. Three things found on the way and in no phase are recorded beside the table: the library's unsynchronised global-preferences cache, reached by two threads at once for the first time, now behind JvmGlobalPrefs on the jvm target; the same cache's consequence for tests that construct the sovereign view model; and the gift wrap seal's link to its wrap, a foreign key that existed and was never written until the inbox sweep asked the question it answers. The README's row and reading order say the note is built, and name switchToIdentity where they named switchToWallet. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@29027f2b922fb12d705547c7e2161af5d9c358e8 --- docs/README.md | 7 +++--- docs/multiple-profiles.md | 46 +++++++++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/README.md b/docs/README.md index f5cf622c..036e24fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,8 +58,9 @@ The npub sign-in note is a phased plan that has been built, and reads as that plan's out-of-scope note answered: it takes the `Identity` type and the sign-in machine as given and asks what an identity with no secret is for, before it asks how to build one; read its table of where the build chose differently first. -The multiple-profiles note is a phased plan that has not been built, and reads as -the third of the sign-in notes: it asks what happens when the device holds two +The multiple-profiles note is a phased plan that has been built, and reads as the +third of the sign-in notes: it asks what happens when the device holds two identities, and its first phase changes one rule the other two share — a seed's key becomes a credential like any other — so read it with `StoredIdentity.merge`, -`SovereignWalletViewModel.switchToWallet` and the startup screen open. +`SovereignWalletViewModel.switchToIdentity` and the startup screen open, and read +its table of where the build chose differently first. diff --git a/docs/multiple-profiles.md b/docs/multiple-profiles.md index 2f6bedef..f37649d0 100644 --- a/docs/multiple-profiles.md +++ b/docs/multiple-profiles.md @@ -11,10 +11,41 @@ stores, the sign-in screen and the forget sequence as given, changes one rule th two of them share, and asks the question both deferred without naming it: what happens when the device holds two of them. -**Not built.** Phases 1–8 are one commit each, in the order given; Phase 9 is the -rollout. The phases are written as reasoning rather than as a checklist, for the -reason the two sign-in plans give: the code reads better against the argument it -came from. +**Built**, phases 1–8, one commit each, in the order given; Phase 9 is the rollout +and is process rather than code. The phases are kept as written because they are +the reasoning, and the code reads better against the argument it came from than +against a summary of itself. Where the implementation chose differently the table +below says so: + +| what the plan said | what it turned out to be | +|---|---| +| the repair runs "before the credentials file is read for the listing" | after both files are read, and the listing is built from what it returns — the repaired map, or the one read if the write failed — so the file is decrypted once | +| `NotACredential` becomes `WalletAttached` for a key a seed derives | and for a key not in the file at all, which since the repair can only be a seed's key the repair could not write; one outcome, two ways in | +| `stopPlatformBusiness` called from `switchToIdentity` | injected into the view model as a function, defaulting to the `expect`, so the branch can be pinned with a recorder — a node cannot be started in a test, but a `PhoenixBusiness` can be built, since everything in it is lazy | +| the relay observer restructured | as planned, plus an injectable scope and a `relayUrls` read for the test that fails against the old observer | +| `setActiveIdentity` saves the default | wrapped: a default that could not be written is a selector on the next boot, not a crash now. The forget tails call `forgetDefaultIdentity()` on the view model rather than `clearDefaultWallet()` inline | +| "Initializing…" among the four that become *opening profile* | it and "Preparing wallet…" both became *preparing profiles*; they are the same wait | +| `ProfilesViewModel` "owns the four states" | takes the four flows it joins rather than the view model that holds them, so a compose test can drive it with `MutableStateFlow`s and a fake repository; the fourth state, empty, is not modelled, as the plan said it could not happen | +| a row's fallback "the npub and the metadata's emoji" | the npub as the title over the emoji avatar, with no second line; a key with a kind 0 gets the name over the picture with the npub under it | +| "`createAccount` already takes its writer as a function" | a `NewProfileWriter` interface returning the key it derived and the id it filed it under, with `seedProfileWriter` and `bareKeyProfileWriter` on the view model and `CreateProfileRoute(withWallet)` choosing; the view model plants the six events *after* the write and calls the tail after that, so the account is in the database before the identity is activated — an ordering the create flow used to get away with by luck | +| `CredentialProblem.AlreadyOnThisDevice` "carries it" | the id rides on `Outcome.Failed` and the `Error` state instead; `CredentialProblem` stays an enum and the parser's tests stay as they were | +| the DAO's own requests "stamped from the `activeKeyPair` it is already handed" | left unowned, on purpose: the placeholder-profile syncs and a Marmot join's participant syncs fetch public kinds that need no key to open, and any profile that is open may as well fetch them. Only what goes through the repository is stamped | +| `openGiftWrap` lifted out of `indexNostrEvent` | not lifted: the branch is a screenful of room and participant handling, and running the stored wrap through `indexNostrEvent` again, in a transaction of its own per wrap, gives the same result with one code path. `reopenGiftWrap` is that | +| `ForgetIdentityJvmTest` gains "forgetting B while A remains" | the round trip covers it, forgetting B and C and listing A alone; the per-outcome tests were done in Phase 1 | +| the round trip "creates A from a phrase" | through the create view model with the seed writer, as Landing does, rather than through a pasted phrase; and A's node is a `PhoenixBusiness` that was never started, which is enough for the switch to have something to stop | + +Three things found on the way that are in no phase. `DataStoreManager` caches one +`GlobalPrefs` per process behind an unsynchronised check-then-set, and DataStore +refuses a second instance over the same file: two first calls at once — the listing +on IO and a sign-in's write — could both build one, and the loser threw at first +use. `JvmGlobalPrefs` is now the one way the jvm target reaches the prefs, under a +lock; Android goes through the `Application`'s single instance and never raced. The +same cache means a test that constructs `SovereignWalletViewModel` must not build a +`GlobalPrefs` of its own. And `GiftWrapSeal.giftWrapMessageId`, a foreign key to the +wrap a seal came out of, existed and was never written — so nothing in the database +could say which wraps had been opened, which is the question the inbox sweep asks; +`decryptGiftWrapSeal` now sets it, and a seal from before is swept once and comes +back with the link. ## The vocabulary @@ -1049,6 +1080,13 @@ is a test to rewrite, not to delete. Phase 6's unseal branch of `indexNostrEvent has never been called from anywhere but `indexNostrEvent`, and lifting it out is where a transaction boundary will turn out to have been load-bearing. +Phases 1–8 are now implemented, in one sitting and in order. What each turned out +to require, as against what was predicted here, is in the table at the top and in +the commit messages on this branch. The Phase 6 uncertainty resolved itself by not +lifting the branch at all; the uncertainty that did bite was in no phase — a +process-wide cache in the library, reached by two threads at once for the first +time, which the tests for Phase 5 hit one run in three. + ## Out of scope - **A lazy node.** Phase 1 puts the key in the credential, so a profile with a From 310d3bbbd321aae3c8dabbf389ac6d5a8c4cbce9 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 15:56:26 +0200 Subject: [PATCH 55/71] docs: record Phase 5 of the profiles pull, which completes the ten 9bb34004, 88577bb5 and 29027f2b landed as 8edb52ff, 72b9d3d2 and ba5ef723, all clean -- including the two picks into docs/README.md, where the driver's UNION rule was armed and did not fire: git placed the fork's row between the npub and jvm-target rows and its reading-order sentence after the npub note's, beside the sentence this plan's own commit had added there. The ten are landed. Exactness at the tip is the dry run's: 135 residual files between Mantra and the rewritten fork, CreateProfileViewModel.kt the only file that left the residual since the base, and no other file's residual moved. Both compilers clean, jvmTest 864 -> 868, testDebugUnitTest 420, every audit budget met -- the dry run's numbers exactly, as a deterministic rewrite replayed onto an unchanged base should give. One review point, read: MultipleProfilesRoundTripJvmTest reaches the saved default through the app's getGlobalPrefs rather than building a GlobalPrefs of its own, and says why in a comment, so the per-process cache trap is written at the place a future test would copy from. Co-Authored-By: Claude Opus 5 --- docs/curated-to-mantra-profiles.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md index a6062dc1..c57504db 100644 --- a/docs/curated-to-mantra-profiles.md +++ b/docs/curated-to-mantra-profiles.md @@ -493,6 +493,19 @@ trap `00fc9969` recorded — and Mantra's own future tests inherit that rule. Exit: the full suite at the dry run's numbers or above; the audit green; the README's table has the new row between the npub note and the jvm-target note. +**Built 2026-09-13**, `8edb52ff`, `72b9d3d2`, `ba5ef723`, three clean picks — including +the two into `docs/README.md`, where the `UNION` rule stood ready and was not needed: +git placed the fork's row between the npub and jvm-target rows and its reading-order +sentence after the npub note's, beside this document's own. Exactness at the tip: 135 +residual files, `CreateProfileViewModel.kt` the only one gone since the base, nothing +moved. Both compilers clean; `jvmTest` 864 → 868 (`MultipleProfilesRoundTripJvmTest` and +`ReadOnlyEntrancesJvmTest`'s three); `testDebugUnitTest` 420; audit all budgets met — +exactly the dry run's numbers, which is what a deterministic rewrite replayed onto an +unchanged base ought to give. The round trip reads the saved default through the app's +`getGlobalPrefs` rather than building a `GlobalPrefs` of its own, with a comment saying +why, so the trap the phase warned of is documented at the place a copy-and-paste would +start from. + ### Phase 6 — close the loop Five things, none of them a merge. From 679d5249b25b0b6628d81e463097a1e2585ba52a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 16:07:45 +0200 Subject: [PATCH 56/71] fix(groups): refuse a welcome for a group another profile on this device holds, and say so in the room Phase 6 of docs/curated-to-mantra-profiles.md, the fourth decision's follow-up, written natively before the several-profiles line reaches users. ChatRoom is keyed by the group id alone, and the MLS state, the DKG sessions and the FROST shares all hang off the room; before several profiles on a device the second local member could not exist, and after it a user can invite their own second profile into their own collective. What the DAO did with that Welcome was already a refusal, by accident: the welcome branch of indexNostrEvent checks for an existing room before it writes one, so the joiner's state never overwrote the holder's -- but the branch could not tell a redelivered Welcome for our own room from a Welcome for a room another profile holds, logged "Chat Room already exists" at warning level for both, and told nobody. The invitee never joined, the inviter's device believed the invite had landed, and the failure had the exact shape of "the other device never got it". The branch now distinguishes the two by the room's userPublicKey. The same profile's redelivery stays the quiet no-op it was. Another profile's Welcome is refused with a warning naming both keys, and a membership line is written into the room that exists -- the holder's, because it is the only room on the device the group has and the holder is the one who can act on it: leave, or tell the inviter which profile to invite instead. The line is TYPE_WELCOME_REFUSED_OTHER_PROFILE, in MEMBERSHIP_TYPES so the transcript draws it as a notice rather than a bubble, with the error tint and a PersonOff icon since the person and not the invite is what it is about; its content is a whole sentence naming the inviter, the invitee and the holder, resolved at write time the way the other membership lines are. The invitee's key package bundle is left unconsumed, as the branch always left it; a second Welcome for the same package arrives here again and writes a second line. The test is two devices for real: an MLS group on the inviter's database, a key package whose private keys the invitee's database holds -- the fixture now returns the bundle beside the public package, and marmotKeyPackageFor is a projection of it -- and the Welcome the invite queued, sealed the way sealGiftWrapPayload seals one and stored through storeNostrEvent under the invitee's key. Three cases: the group held by another profile is refused, its row and MLS state untouched, the line in the room naming all three and the bundle unconsumed; the same profile welcomed twice writes nothing; and with no profile in the way the Welcome is a join, which is what shows the fixture is real. jvmTest 868 -> 871, both compilers clean, every audit budget met. Co-Authored-By: Claude Opus 5 --- .../mantra/compose/database/dao/NostrDao.kt | 66 +++++ .../compose/database/model/ChatMessage.kt | 15 ++ .../composable/widgets/chat/ChatTranscript.kt | 7 +- .../database/dao/MarmotKeyPackageFixture.kt | 37 ++- .../dao/WelcomeForOtherProfileJvmTest.kt | 241 ++++++++++++++++++ 5 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 956d7fbd..f2263e8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -29,6 +29,7 @@ import press.mantra.compose.exceptions.MarmotMissingKeyPackageBundleException import press.mantra.compose.exceptions.MarmotMissingNostrGroupDataExtension import press.mantra.compose.exceptions.MarmotNotMemberOfChatGroupException import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException +import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.Relays @@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -915,6 +917,23 @@ abstract class NostrDao( // content = "Coming soon.", // TODO: Figure out what to do here... // ) // ) + } else if (localChatRoom.chatRoom.userPublicKey != activeKeyPair.pubKey.toHex()) { + // Another profile on this device already holds this + // group. ChatRoom is keyed by the group id alone, and + // every table that hangs off it -- the MLS state, the + // DKG sessions, the FROST shares -- is keyed by the + // room, so letting a second local member in would + // overwrite the first's state with the joiner's and + // break both. Refuse, and say so where the group is: + // in the room, under the profile that holds it. See + // docs/curated-to-mantra-profiles.md, the fourth + // decision. + refuseWelcomeForOtherProfile( + localChatRoom = localChatRoom, + inviteePublicKey = activeKeyPair.pubKey.toHex(), + inviterPublicKey = decryptedGiftWrapPayload.publicKey, + welcomePayloadId = decryptedGiftWrapPayload.id, + ) } else { logger.w("Chat Room already exists for $nostrGroupId") } @@ -1652,6 +1671,53 @@ abstract class NostrDao( * room needs. Creating a group locally sends nothing to anyone, which is why the * first arrival for a group is just as likely to be a key ceremony as a message. */ + /** + * A Welcome for a group this device already holds under another of its profiles, + * refused, and the refusal written into the room as a membership line. + * + * The line goes into the room that exists -- the other profile's -- because that is + * the only place on this device the group has, and the profile that holds it is the + * one who can do something about it: leave, or tell the inviter which profile to + * invite instead. The invitee's key package bundle is left unconsumed, as the + * "already exists" branch has always left it; nothing here changes what a second + * Welcome for the same package would do, which is arrive here again and write a + * second line. + * + * Content is a whole sentence with both names written in, the way the other + * membership lines are, so nothing prefixes an author to it: this is nobody's words. + */ + private suspend fun refuseWelcomeForOtherProfile( + localChatRoom: LocalChatRoom, + inviteePublicKey: HexKey, + inviterPublicKey: HexKey, + welcomePayloadId: HexKey, + ) { + val holder = localChatRoom.chatRoom.userPublicKey + logger.w( + "Refusing Welcome $welcomePayloadId for ${localChatRoom.chatRoom.id}: this device already holds the group " + + "as $holder, and $inviteePublicKey is another profile on it" + ) + + suspend fun nameOf(publicKey: HexKey): String = + database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + + database.chatMessageDao().upsert( + ChatMessage( + content = "${nameOf(inviterPublicKey)} invited ${nameOf(inviteePublicKey)}, another profile on this device, " + + "to this group. The invitation was not accepted: this device already holds the group as " + + "${nameOf(holder)}, and one device cannot hold a group twice.", + messageType = ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE, + chatRoomId = localChatRoom.chatRoom.id, + senderPublicKey = holder, + isUserMessage = true, + giftWrapPayloadId = welcomePayloadId, + marmotGroupEventId = null, + marmotInnerEventId = null, + ) + ) + } + private suspend fun getOrCreateNip17ChatRoom( decryptedGiftWrapPayload: GiftWrapPayload, activeKeyPair: KeyPair, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index f6db7723..efa2b704 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -437,6 +437,20 @@ data class ChatMessage( const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent" const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed" + /** + * A Welcome that arrived for another profile on this device, for a group this + * device already holds, and was refused. + * + * Written by the invitee's side, into the room the other profile holds, since + * that is the only room on the device the group has. ChatRoom is keyed by the + * group id alone and everything that hangs off it -- MLS state, ceremonies, + * shares -- by the room, so a second local member would overwrite the first. + * The inviter's device sees nothing of this and believes the invite landed, + * which is why the line names both profiles: the one who was invited and the + * one who holds the group. See docs/curated-to-mantra-profiles.md. + */ + const val TYPE_WELCOME_REFUSED_OTHER_PROFILE = "welcomeRefusedOtherProfile" + /** * Every membership line, for the one check the transcript dispatches on. * @@ -447,6 +461,7 @@ data class ChatMessage( TYPE_MEMBER_INVITED, TYPE_MEMBER_INVITE_SENT, TYPE_MEMBER_INVITE_FAILED, + TYPE_WELCOME_REFUSED_OTHER_PROFILE, ) const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index 75db2d21..ca9c4bc6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonOff import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material.icons.filled.Info @@ -815,6 +816,9 @@ private fun RitualNotice( ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline + // Somebody on this device was kept out of a group it already holds; the + // person, not the invite, is what the line is about. + ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> Icons.Default.PersonOff else -> Icons.Default.PanTool } @@ -835,7 +839,8 @@ private fun RitualNotice( val tint = when { chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED || - chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED -> + chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED || + chatMessage.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> MaterialTheme.colorScheme.error isRequest -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurfaceVariant diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt index 08a4ef9e..ed4fc7e1 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt @@ -14,6 +14,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.TimeUtils import press.mantra.compose.database.model.MarmotKeyPackage +import press.mantra.compose.database.model.MarmotKeyPackageBundle +import press.mantra.compose.extensions.toHex /** * A real MLS key package for [publicKey], built the same way @@ -21,7 +23,19 @@ import press.mantra.compose.database.model.MarmotKeyPackage * three key generations, a signed leaf node and a signed key package. No relay, no network, * nothing to stub. */ -internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage { +internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage = marmotKeyPackageBundleFor(publicKey).keyPackage + +/** + * The key package and the private keys behind it, as the invitee's own device would hold + * them: [keyPackage] is what the inviter reads off a relay, [bundle] is the row the + * invitee's `processWelcome` opens the Welcome with. [id] is the kind 443 event id the + * Welcome names in its `e` tag, and both rows carry it. + */ +internal class MarmotKeyPackagePair(val keyPackage: MarmotKeyPackage, val bundle: MarmotKeyPackageBundle) { + val id: HexKey get() = keyPackage.id +} + +internal fun marmotKeyPackageBundleFor(publicKey: HexKey, id: HexKey = publicKey): MarmotKeyPackagePair { val initKp = X25519.generateKeyPair() val encKp = X25519.generateKeyPair() val sigKp = Ed25519.generateKeyPair() @@ -65,9 +79,22 @@ internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage { ), ) - return MarmotKeyPackage( - id = publicKey, - publicKey = publicKey, - tlsEncodedMarmotKeyPackage = keyPackage.toTlsBytes(), + val tls = keyPackage.toTlsBytes() + return MarmotKeyPackagePair( + keyPackage = MarmotKeyPackage( + id = id, + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = tls, + ), + // The same three private keys the app stores raw, hex-encoded -- see + // DatabaseMarmotRepository.publishMarmotKeyPackageBundle. + bundle = MarmotKeyPackageBundle( + id = id, + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = tls, + ncryptsecInitPrivateKey = initKp.privateKey.toHex(), + ncryptsecEncryptionPrivateKey = encKp.privateKey.toHex(), + ncryptsecSignaturePrivateKey = sigKp.privateKey.toHex(), + ), ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt new file mode 100644 index 00000000..a0ed8651 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt @@ -0,0 +1,241 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.Relays +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Two of a device's own profiles in one group, which docs/multiple-profiles.md made + * possible and docs/curated-to-mantra-profiles.md's fourth decision refuses. + * + * `ChatRoom` is keyed by the group id alone. Before several profiles on a device the + * second local member could not exist; after it, a user can invite their own second + * profile into their own collective, and a Welcome processed for it would file the + * joiner's MLS state over the holder's. The DAO already declined to overwrite -- "Chat + * Room already exists", at warning level, and nothing else -- so the invitee never + * joined and nobody was told. This pins the refusal and the line that now reports it. + * + * The setup is real: an MLS group on the inviter's database, a key package whose + * private keys the invitee's database holds, and a Welcome sealed the way + * `DatabaseChatRepository.sealGiftWrapPayload` seals one, delivered through + * `storeNostrEvent` under the invitee's key. + */ +class WelcomeForOtherProfileJvmTest { + + /** The inviter's device. */ + private val inviterDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + /** The device that holds two profiles: `holder`, already in the group, and `invitee`. */ + private val deviceDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + @AfterTest + fun closeDbs() { + inviterDb.close() + deviceDb.close() + } + + private val relay = Relays.DefaultDMRelayList.first().url + private val inviter = KeyPair() + private val invitee = KeyPair() + private val holder = KeyPair() + private val roomId = "b".repeat(64) + private val keyPackageEventId = "d".repeat(64) + + private suspend fun MantraDatabase.seedProfile(publicKey: HexKey, name: String, nostrEventId: String) { + nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + profileDao().upsert(Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId)) + } + + /** A room with real MLS state on the inviter's device, the inviter its only member. */ + private suspend fun seedInviterRoom(): LocalChatRoom { + val inviterHex = inviter.pubKey.toHexKey() + inviterDb.seedProfile(inviterHex, "inviter", "1".repeat(64)) + inviterDb.seedProfile(invitee.pubKey.toHexKey(), "invitee", "2".repeat(64)) + val mlsGroup = MlsGroup.create( + identity = inviterHex.hexToByteArray(), + initialExtensions = listOf( + MarmotGroupData.bootstrap( + nostrGroupId = roomId, + creatorPubKey = inviterHex, + outboxRelays = listOf(relay), + ).toExtension() + ), + ) + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = inviterHex, + subject = "the collective", + description = null, + mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), + ) + inviterDb.chatRoomDao().upsert(chatRoom) + return LocalChatRoom(chatRoom = chatRoom) + } + + /** + * The inviter adds the invitee and the Welcome reaches the queue; this seals it for + * the invitee the way the notary would, and hands back the wrap as a relay would. + */ + private suspend fun welcomeWrapForInvitee(): NostrEvent { + // The room first: MarmotKeyPackage.publicKey is a foreign key onto Profile, and + // the room's seeding is what gives the invitee one on the inviter's device. + val localChatRoom = seedInviterRoom() + val pair = marmotKeyPackageBundleFor(invitee.pubKey.toHexKey(), id = keyPackageEventId) + inviterDb.marmotKeyPackageDao().upsert(pair.keyPackage) + deviceDb.marmotKeyPackageBundleDao().upsert(pair.bundle) + + inviterDb.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = localChatRoom, + peerPublicKey = invitee.pubKey.toHexKey(), + peerKeyPackage = pair.keyPackage, + ) + val payload = inviterDb.giftWrapPayloadDao() + .getByChatRoomAndKinds(roomId, listOf(WelcomeEvent.KIND)) + .single() + + val signer = NostrSignerSync(inviter) + val rumor = RumorAssembler.assembleRumor( + pubKey = signer.pubKey, + ev = EventTemplate( + createdAt = payload.createdAt.epochSeconds, + kind = payload.kind, + tags = payload.tags, + content = payload.content, + ), + ) + val seal = signer.signNormal( + createdAt = 1_700_000_000, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(plaintext = rumor.toJson(), toPublicKey = invitee.pubKey.toHexKey()), + ) + val wrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = invitee.pubKey.toHexKey(), + createdAt = 1_700_000_100, + ) + return NostrEvent( + id = wrap.id, + pubKey = wrap.pubKey, + kind = wrap.kind, + tags = wrap.tags, + content = wrap.content, + sig = wrap.sig, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + ) + } + + /** The group, already on the device under [owner], with state that is recognisably not the joiner's. */ + private suspend fun seedDeviceRoom(owner: KeyPair): ChatRoom { + val ownerHex = owner.pubKey.toHexKey() + deviceDb.seedProfile(ownerHex, "holder", "3".repeat(64)) + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = ownerHex, + subject = "the collective", + description = null, + mlsGroupState = "0123456789abcdef", + ) + deviceDb.chatRoomDao().upsert(chatRoom) + return chatRoom + } + + private suspend fun deliverToInvitee(wrap: NostrEvent) = deviceDb.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = invitee, + ) + + @Test + fun `a welcome for a group another profile on the device holds is refused and said so`() = runBlocking { + val before = seedDeviceRoom(holder) + deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64)) + // The holder is in the group with the inviter, so this device knows them by name. + deviceDb.seedProfile(inviter.pubKey.toHexKey(), "inviter", "5".repeat(64)) + + deliverToInvitee(welcomeWrapForInvitee()) + + val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId)).chatRoom + assertEquals(before.userPublicKey, room.userPublicKey, "the room changed hands") + assertEquals(before.mlsGroupState, room.mlsGroupState, "the holder's MLS state was overwritten") + + val line = deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage } + .singleOrNull { it.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE } + assertNotNull(line, "the refusal left no line in the room") + assertTrue(line.content.contains("second self"), "the line does not name the invitee: ${line.content}") + assertTrue(line.content.contains("holder"), "the line does not name who holds the group: ${line.content}") + assertTrue(line.content.contains("inviter"), "the line does not name the inviter: ${line.content}") + assertTrue(line.messageType in ChatMessage.MEMBERSHIP_TYPES, "the line would render as a bubble") + + val bundle = assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId)) + assertFalse(bundle.consumed, "a refused welcome must not consume the key package") + } + + /** The branch the refusal was carved out of: the same profile, welcomed twice, is still a quiet no-op. */ + @Test + fun `a welcome for a group the same profile already holds writes nothing`() = runBlocking { + seedDeviceRoom(invitee) + + deliverToInvitee(welcomeWrapForInvitee()) + + assertEquals( + emptyList(), + deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage.messageType }, + "a redelivered welcome for our own room is not a refusal", + ) + } + + /** And with nothing in the way, the Welcome is a join, which is what tells the fixture is real. */ + @Test + fun `the same welcome joins when no profile on the device holds the group`() = runBlocking { + deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64)) + + deliverToInvitee(welcomeWrapForInvitee()) + + val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId), "the invitee did not join").chatRoom + assertEquals(invitee.pubKey.toHexKey(), room.userPublicKey) + assertNotNull(room.mlsGroupState, "the joiner's MLS state was not filed") + assertTrue( + assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId)).consumed, + "a join consumes the key package", + ) + } +} From 2620e43d5746ca2433a388f8b1d84444333d3af5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 16:08:16 +0200 Subject: [PATCH 57/71] docs: re-point the profiles note's four line anchors at Mantra's tree The note arrived with the pull and anchors four of its links to line numbers -- three into MantraNavHost.kt and one into DatabaseNostrRepository.kt -- that were the fork's. Mantra's nav host is 264 lines shorter than the fork's, having lost the group-identity block in the removal, and its repository grew a constructor parameter in Phase 6 of the same plan, so all four pointed a few lines wide of what the sentence around them describes: the key read off the current route (now :404), the sign-in and create tails (:741 and :537), and the pump reads that used to drain whatever was pending (:118). They describe the tree before the line was built, which is what that section of the note is about, and each still lands on the thing described; only the numbers moved. Phase 6, item 2, of docs/curated-to-mantra-profiles.md. Co-Authored-By: Claude Opus 5 --- docs/multiple-profiles.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/multiple-profiles.md b/docs/multiple-profiles.md index f37649d0..27c9705d 100644 --- a/docs/multiple-profiles.md +++ b/docs/multiple-profiles.md @@ -106,7 +106,7 @@ Every screen from the home tab inward takes `activeUserPublicKey` as a route argument — `HomeRoute`, `SearchRoute`, `ActiveProfileRoute`, `ChatRoomDetailRoute`, all of them. The navigation component reads the signed-in key *off the current route* rather than holding it -([MantraNavHost.kt:418](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)), +([MantraNavHost.kt:404](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)), precisely so that it cannot drift from the screen underneath. That is the right design for one profile and it decides the shape of a switch: a stack of routes built for key A cannot be handed key B. The only honest switch is to clear the @@ -142,7 +142,7 @@ application, and its switching machinery came across intact and unused. | `WalletsSelector`: a current section with a divider, the others below, a *read only* label, `topContent` and `bottomContent` slots | [WalletsSelector.kt:59](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | built; shows "Default name" and a random emoji for every row | | `BusinessManager`: a map of running nodes by wallet id; starting one that runs returns it; `stopBusiness(walletId)` on all three platforms | [BusinessManager.kt:208](../lightning-kmp-app/library/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt) | built; nothing in the app calls `stopBusiness` | | `GlobalPrefs.getDefaultWallet`, `saveDefaultWallet`, `clearDefaultWallet` | [GlobalPrefs.kt:102](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt) | read by startup; never written by anything | -| the sign-in and create tails: re-list, select, go to startup | [MantraNavHost.kt:746](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), [:545](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt) | built; a shape that works from inside the app as well as from Landing | +| the sign-in and create tails: re-list, select, go to startup | [MantraNavHost.kt:741](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), [:537](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt) | built; a shape that works from inside the app as well as from Landing | | *Change account* on the profile tab | [ActiveProfileScreen.kt:309](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt) | `ImplementationPendingRoute` | | `ForgetIdentity` and `SignOutViewModel` for a read-only identity; *forget this key* for an nsec | `identity/ForgetIdentity.kt`, `NostrSecretScreen` | built; the tail lands on the selector or Landing, which is right for several profiles too | | `NpubPreviewRoundTripJvmTest`: a real key store, a real in-memory database, real view models | `jvmTest/.../identity/` | the harness every test below reuses | @@ -207,7 +207,7 @@ running for a profile that is not active is that finding with the sign flipped. **The queues belong to the device, not the identity.** `SynchronizeNostrEventRequest`, `NegentropySynchronizeRequest` and `BroadcastNostrEventRequest` have no owner column, and the pumps drain whatever is `pending` with the *active* key pair -([DatabaseNostrRepository.kt:111](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)). +([DatabaseNostrRepository.kt:118](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)). Requests A queued a moment before the switch — its sign-in sync, its room reconciliation — are answered while B is active, and everything that comes back is indexed as B: a gift wrap addressed to A is stored, found not to be addressed From 213535278aa1e3d7f742a0ff5143a2779056a7f5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 16:09:22 +0200 Subject: [PATCH 58/71] docs: record what the profiles pull built, and the reverse direction measured The plan's top now says it was built, the same day it was written: the ten commits in four phases at the dry run's numbers exactly, Phase 6's two native items done, and its other three -- pushing the removal, the two reverse-pulls, the fork decision -- named as the user's. Phase 6 gains its own Built paragraph: what the refusal changed and did not change in the DAO's welcome branch, the four re-pointed anchors, and a measurement the plan had only asserted -- that a Mantra commit offers itself to the fork cheaply. git format-patch on the refusal commit, four inverse rules over the patch text, and git apply --check on a scratch worktree of curated/curated at 29027f2b: it applies cleanly, five files. That is the shape a normaliser running the other way would take, and it is recorded so the fork can take the commit in seconds rather than by hand. The README's reading-order sentence and the first plan's pointer say "built" where they said "planned". Co-Authored-By: Claude Opus 5 --- docs/README.md | 8 +++--- docs/curated-to-mantra-profiles.md | 42 ++++++++++++++++++++++++++++++ docs/curated-to-mantra.md | 4 +-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/docs/README.md b/docs/README.md index 036e24fb..fd5cf254 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,10 +46,10 @@ alone, except that its first decision leans on the derivation note's one rule. T the lines it pulled -- the group's nostr identity and the curated lists -- were taken out again the same day, and its record says what went and what stayed. The nsec and npub sign-in notes arrived with it and record what they built. The profiles pull note -is the same exercise a second time, planned with its dry run already done; read it -after the first, because it assumes the method and the three decisions and only says -what changed — chiefly that the tree is no longer a superset, and what the check for an -exact pull becomes when it is not. +is the same exercise a second time, planned with its dry run already done and built +the same day; read it after the first, because it assumes the method and the three +decisions and only says what changed — chiefly that the tree is no longer a superset, +and what the check for an exact pull becomes when it is not. The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md index c57504db..6ff8936d 100644 --- a/docs/curated-to-mantra-profiles.md +++ b/docs/curated-to-mantra-profiles.md @@ -16,6 +16,16 @@ lessons; this document assumes both and adds what changed. The one rule everythi still rests on is the derivation note's: two strings in this tree are hash inputs and are never renamed, and the normaliser cannot touch them by construction. +**Built 2026-09-13**, the same day, on the branch that carries this plan: the ten +commits landed by cherry-pick in four phases, each with a `Pulled-From` trailer, at +the dry run's numbers exactly; the phases below carry a *Built* paragraph each with +what was measured at the cut. Phase 6's two native items are done — the refusal of a +Welcome for a group another profile holds (`679d5249`), and the note's four line +anchors (`2620e43d`) — and its other three are the user's: pushing the removal, the +two reverse-pulls into the fork, and the fork decision. The reverse direction was +measured rather than assumed: the refusal commit, rewritten into the fork's names by +four inverse rules, applies cleanly to `curated/curated` at `29027f2b`. + Written against `mantra` at `776455ec` — `origin/mantra` at `0fa807a6` plus the removal commit, which is where this plan starts and which `origin` does not yet carry — and `curated/curated` at `29027f2b`. Dry run executed 2026-09-13. @@ -535,6 +545,38 @@ Five things, none of them a merge. which is best; it only shows that the third is affordable for as long as the fork's work stays clear of what Mantra took out. +**Built 2026-09-13**, items 1 and 2; items 3 to 5 are not this repository's to land. + +*Item 1*, `679d5249`. The DAO's welcome branch already declined to overwrite an +existing room — "Chat Room already exists", at warning level — so the joiner's MLS state +never replaced the holder's; what it could not do was tell a redelivered Welcome for our +own room from a Welcome for a room another profile holds, and it told nobody about +either. The branch now reads the room's `userPublicKey`: the same profile's redelivery +stays a quiet no-op, and another profile's Welcome is refused with a warning naming +both keys and a membership line written into the room that exists — the holder's, +since it is the only room on the device the group has and the holder is the one who +can act. `TYPE_WELCOME_REFUSED_OTHER_PROFILE`, in `MEMBERSHIP_TYPES`, error tint, +`PersonOff`; the content names inviter, invitee and holder. The key package bundle is +left unconsumed as the branch always left it. The test is two databases with a real +MLS group, a key package whose private keys the invitee's side holds — the fixture +now returns the bundle beside the public package — and a Welcome sealed as the app +seals one; three cases, refused, redelivered, joined. `jvmTest` 868 → 871. + +*Item 2*, `2620e43d`: the four anchors re-pointed at Mantra's lines — `:404`, `:741`, +`:537`, `:118` — each still landing on the thing its sentence describes. + +*Item 4, measured.* `git format-patch -1 679d5249`, four inverse rules over the +patch text (`press.mantra.` → `to.curare.`, `press/mantra/` → `to/curare/`, +`mantra.composeapp.generated.resources` → `curare.…`, `MantraDatabase` → +`CurareDatabase`), and `git apply --check` on a scratch worktree of `curated/curated` +at `29027f2b`: applies cleanly, 5 files, +360 −6. That is the whole cost of offering +a Mantra commit to the fork while both trees hold the touched files identically — +and it is also the shape a `curated-rebrand.py`, the normaliser's inverse, would +take if the fork wanted to pull regularly rather than by hand. + +*Items 3 and 5* are unchanged from the plan: two small commits in the other +repository, and a decision. + ## Risks, and the silent ones in particular **The write on first launch.** Phase 2's repair is the first time a pull has changed a diff --git a/docs/curated-to-mantra.md b/docs/curated-to-mantra.md index 5b4d2a32..7f168840 100644 --- a/docs/curated-to-mantra.md +++ b/docs/curated-to-mantra.md @@ -61,8 +61,8 @@ The upstream moved before this landed: `curated/curated` went from `86cb876b` to `29027f2b`, ten commits for several profiles on one device, one of them the fork's first schema migration (version 20). They are the next pull, and the schema change is why they get their own decision rather than a ride on this one — which is -[curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md), planned and dry-run the -same day. +[curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md), planned, dry-run and +landed the same day. Written against `origin/mantra` at `ba26c0b1` and `curated/curated` at `86cb876b`; executed on 2026-09-13. From 0728f3f969c0d4083fc4b3d1c43772dd93dbf26b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 16:29:23 +0200 Subject: [PATCH 59/71] chore(scripts): teach the normaliser the brand inside a CamelCase identifier The fork's profile-preview line names a Readiness state NotYetOnCurare, and the normaliser's \bCurare\b rule cannot see a brand that has no word boundary in front of it: the first rewrite of the eleven new commits left seventeen occurrences of it in code, tests and the note. One rule, for a brand preceded by a lower-case letter or digit and followed by an upper case letter or the end of the word, maps it to NotYetOnMantra. Only the Curare generation gets the rule: "Curated" is product vocabulary at the tip (CuratedSchemaEvent) and a CamelCase-embedded Curated is therefore not a brand. Co-Authored-By: Claude Opus 5 --- docs/scripts/curated-unbrand.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/scripts/curated-unbrand.py b/docs/scripts/curated-unbrand.py index a415e543..01853f66 100755 --- a/docs/scripts/curated-unbrand.py +++ b/docs/scripts/curated-unbrand.py @@ -85,6 +85,9 @@ _RULES = [ (r'(? `NotYetOnMantra` + (r'(?<=[a-z0-9])Curare(?=[A-Z]|\b)', 'Mantra'), # the brand word itself, in comments, string values and docs (r'\bCurare\b', 'Mantra'), (r'\bcurare\b', 'mantra'), From 69aa43a108eb28e970e906fe453211bfbdcd6a5e Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:49:41 +0200 Subject: [PATCH 60/71] docs: plan a profile preview before a chat, starting from what the button is allowed to do The "Direct message via npub" option goes straight from a pasted string to an MLS room. The plan puts a screen between them, keyed by public key because that is all the dialog has, and keeps the room's creation where it is: four decisions -- where the preview lives, what "found" means when a row can be a placeholder, that the button hands over rather than creates, and that it waits for the key package so the twenty-second dead end is answered before the press -- and four commit-sized phases with the tests for each. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@db268a53d3f3a0c8facd0b675b894c5a89b55be2 --- docs/README.md | 6 + docs/npub-profile-preview.md | 788 +++++++++++++++++++++++++++++++++++ 2 files changed, 794 insertions(+) create mode 100644 docs/npub-profile-preview.md 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. From f84ae133948989c8c7b0ba718b54b2951a300cc0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:50:15 +0200 Subject: [PATCH 61/71] feat(chat): the lookup behind a profile preview, and the sync it shares with the chat screen Phase 1 of docs/npub-profile-preview.md. No screen yet: a view model that finds the person behind a pasted npub and says when it has stopped looking, over the row the device already holds -- cached first -- and two asks of the relays. The three-kind filter the chat screen queued privately is now DirectMessagePeerSync.requests, and the kind 0 alone goes to the indexers as profileRequests: a key pasted from elsewhere has lived elsewhere, and a three-kind filter sent to an indexer comes back with the kind 0 and nothing else. "A placeholder is not a profile" was spelled out twice as a comparison against GENESIS_AT; it is Profile.isResolved() now, and both sites read it. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@37c6f7eabba032eff6b44dd1139115482ec63568 --- .../mantra/compose/database/model/Profile.kt | 9 + .../compose/nostr/DirectMessagePeerSync.kt | 96 ++++++++ .../mantra/compose/nostr/MemberProfileSync.kt | 5 +- .../view/model/ChatRoomMessagingViewModel.kt | 40 +--- .../ui/view/model/ProfilePreviewViewModel.kt | 133 +++++++++++ .../ui/view/state/ProfilePreviewUIState.kt | 27 +++ .../nostr/DirectMessagePeerSyncTest.kt | 90 ++++++++ .../model/ProfilePreviewViewModelJvmTest.kt | 214 ++++++++++++++++++ 8 files changed, 577 insertions(+), 37 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt index f7d1ba98..ecaa861c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt @@ -24,6 +24,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRout import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.database.GENESIS_AT import kotlin.time.Clock import kotlin.time.Instant import press.mantra.compose.ui.theme.ConformancePreviews @@ -72,6 +73,14 @@ data class Profile( press.mantra.compose.database.model.traits.UserViewableEntity, press.mantra.compose.database.model.traits.BroadcastableEntity, press.mantra.compose.database.model.traits.SoftDeletableEntity { + /** + * Read off a kind 0, as against the "LOADING..." placeholder minted when a pubkey + * is first seen (`NostrDao.indexNostrEvent`), which is stamped [GENESIS_AT] so that + * the two can be told apart. A missing row and a placeholder row mean the same + * thing to a reader: nobody has read a kind 0 for that pubkey. + */ + fun isResolved(): Boolean = createdAt > GENESIS_AT + fun humanReadableNameOrPubkey(): String { return if (!displayName.isNullOrBlank()) { displayName diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt new file mode 100644 index 00000000..a77c4819 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/DirectMessagePeerSync.kt @@ -0,0 +1,96 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.types.SynchronizationFilter + +/** + * 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. + * + * Both at level 0, 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.indexNostrEvent`). From an indexer that follow-up asks the indexer for + * key packages it does not hold, which is harmless and is why [requests] is queued + * explicitly rather than relied on. + * + * 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" + + /** The kind 0 on its own, for the preview that shows a person before a chat is started. */ + const val PROFILE_PURPOSE = "profile-preview" + + /** + * What a chat needs: the peer's metadata, their MLS key package and their DM + * relay list. + * + * No `limit` on the filter built from these: it is ignored on the negentropy path + * but honoured on the REQ fallback, where `limit = 1` returned a single newest + * event across all three kinds -- almost always the kind 0 -- so the key package + * never arrived. + */ + val KINDS = arrayOf( + MetadataEvent.KIND, + KeyPackageEvent.KIND, + ChatMessageRelayListEvent.KIND, + ) + + /** + * One negentropy request per DM relay for the three kinds, authored by + * [peerPublicKey]. The id is `computeId`'s, bucketed by minute, so a retry inside + * the same minute re-arms the row rather than adding one. + */ + fun requests(peerPublicKey: HexKey): List { + val synchronizationFilter = SynchronizationFilter( + kinds = KINDS, + authors = arrayOf(peerPublicKey), + ) + + return Relays.DefaultDMRelayList.map { relay -> + NegentropySynchronizeRequest( + id = NegentropySynchronizeRequest.computeId( + relayURL = relay.url, + synchronizationFilter = synchronizationFilter, + ), + purpose = PURPOSE, + synchronizationFilter = synchronizationFilter, + relayURL = relay.url, + level = 0, + ) + } + } + + /** + * The kind 0 alone, as a REQ to `SignInSync.bootstrapRelays` -- the indexers plus + * our own -- which is where a key pasted from elsewhere has lived. + */ + fun profileRequests(peerPublicKey: HexKey): List { + val synchronizationFilter = SynchronizationFilter( + kinds = arrayOf(MetadataEvent.KIND), + authors = arrayOf(peerPublicKey), + ) + + return SignInSync.bootstrapRelays.map { relay -> + SynchronizeNostrEventRequest( + purpose = PROFILE_PURPOSE, + synchronizationFilters = arrayOf(synchronizationFilter), + relayURL = relay.url, + level = 0, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt index f245e9a6..01b40b9d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/MemberProfileSync.kt @@ -2,7 +2,6 @@ package press.mantra.compose.nostr import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter @@ -11,7 +10,7 @@ import press.mantra.compose.database.model.types.SynchronizationFilter * Asking the relays who a room's members are, for the members this device cannot name. * * A pubkey gets a Profile row the moment it is first seen, and that row is a - * placeholder: the "LOADING..." display name, stamped [GENESIS_AT] so it is + * placeholder: the "LOADING..." display name, stamped `GENESIS_AT` so it is * distinguishable from a profile actually read off a kind:0. What replaces it is a * kind:0, and every path that mints a placeholder asks for one -- exactly once, on * whichever relay that pubkey happened to arrive on. @@ -55,7 +54,7 @@ object MemberProfileSync { ): List = rooms .flatMap { it.localParticipants } - .filter { participant -> participant.profile?.let { it.createdAt > GENESIS_AT } != true } + .filter { participant -> participant.profile?.isResolved() != true } .map { it.participant.participantPublicKey } .filter { it != activeUserPublicKey } .distinct() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt index 07937d03..37fce883 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt @@ -9,20 +9,14 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import press.mantra.compose.database.GENESIS_AT -import press.mantra.compose.database.model.NegentropySynchronizeRequest -import press.mantra.compose.database.model.types.SynchronizationFilter -import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.DirectMessagePeerSync import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import co.touchlab.kermit.Logger -import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.delay @@ -133,7 +127,7 @@ class ChatRoomMessagingViewModel( ) { profile, keyPackage -> profile to keyPackage } .distinctUntilChanged() .collect { (profile, keyPackageEvent) -> - val profileIsResolved = profile != null && profile.createdAt > GENESIS_AT + val profileIsResolved = profile?.isResolved() == true if (!profileIsResolved || keyPackageEvent == null) { // Whatever is missing, ask the relays for it. This used to be queued @@ -177,35 +171,13 @@ class ChatRoomMessagingViewModel( /** * Ask the DM relays for everything needed to start a chat with [chatRoomId] (a pubkey here): - * the peer's metadata, their MLS key package and their DM relay list. + * the peer's metadata, their MLS key package and their DM relay list. The filter and + * the reasons behind it live in `DirectMessagePeerSync`, which the profile preview + * queues too. */ private suspend fun scheduleProfileAndKeyPackageSync() { - val synchronizationFilter = SynchronizationFilter( - kinds = arrayOf( - MetadataEvent.KIND, - KeyPackageEvent.KIND, - ChatMessageRelayListEvent.KIND - ), - authors = arrayOf( - chatRoomId - ) - // No `limit`: it is ignored on the negentropy path but honoured on the REQ - // fallback, where `limit = 1` returned a single newest event across all three - // kinds — almost always the kind-0 — so the key package never arrived. - ) nostrRepository.queueNegentropySynchronizeRequest( - Relays.DefaultDMRelayList.map { relay -> - NegentropySynchronizeRequest( - id = NegentropySynchronizeRequest.computeId( - relay.url, - synchronizationFilter = synchronizationFilter - ), - purpose = "initiate-chat", - synchronizationFilter = synchronizationFilter, - relayURL = relay.url, - level = 0 - ) - } + DirectMessagePeerSync.requests(chatRoomId) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt new file mode 100644 index 00000000..f7b299a5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt @@ -0,0 +1,133 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Finds the person behind a pasted npub, and says when it has stopped looking. + * + * Cached first: the observed row is whatever the device already holds, so a person + * already known is on screen before any relay answers, and the relays refresh them + * behind it. The relays are asked twice over -- the kind 0 of the indexers, which hold + * everyone's, and the three kinds a chat needs of the DM relays -- through + * [DirectMessagePeerSync], so that pressing the button on the screen after this one + * finds the key package already here. + * + * The timeout is the one both DM view models use, and it only changes what is shown: + * the collect stays live past it, so a kind 0 that arrives on the twenty-first second + * still loads. See docs/npub-profile-preview.md, Phase 1. + */ +class ProfilePreviewViewModel( + val activeUserPublicKey: HexKey, + val profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState, + private val nostrRepository: NostrRepository, + /** How long the relays get before the screen says it came up empty. A parameter so a test need not wait it out. */ + private val relayLookupTimeout: Duration = RELAY_LOOKUP_TIMEOUT, +) : ViewModel() { + + var profilePreviewUIState: ProfilePreviewUIState by mutableStateOf(initialProfilePreviewUIState) + private set + + private val logger = Logger.withTag(TAG) + + private var started = false + private var lookupTimeout: Job? = null + + /** Once. A recomposition that calls this again must not open a second collect. */ + fun initiate() { + if (started) return + started = true + observeProfile() + askTheRelays() + } + + /** Asks again. The observation is still live, so the answer arrives through the same collect. */ + fun retry() { + profilePreviewUIState = ProfilePreviewUIState.Loading + askTheRelays() + } + + private fun observeProfile() { + viewModelScope.launch(Dispatchers.IO) { + nostrRepository.observeProfileWithPublicKey(profilePublicKey) + // A placeholder is not a person. Mapping it to null here is what keeps + // "LOADING..." off a screen whose one job is to say who this is. + .map { profile -> profile?.takeIf { it.isResolved() } } + .distinctUntilChanged() + .collect { profile -> + if (profile == null) return@collect + lookupTimeout?.cancel() + profilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile) + } + } + } + + private fun askTheRelays() { + lookupTimeout?.cancel() + lookupTimeout = viewModelScope.launch(Dispatchers.IO) { + delay(relayLookupTimeout) + if (profilePreviewUIState is ProfilePreviewUIState.Loading) { + profilePreviewUIState = ProfilePreviewUIState.NotFound + } + } + + viewModelScope.launch(Dispatchers.IO) { + try { + nostrRepository.queueSynchronizeNostrEvent( + DirectMessagePeerSync.profileRequests(profilePublicKey) + ) + nostrRepository.queueNegentropySynchronizeRequest( + DirectMessagePeerSync.requests(profilePublicKey) + ) + } catch (e: Exception) { + logger.e("could not ask the relays about $profilePublicKey", e) + lookupTimeout?.cancel() + profilePreviewUIState = ProfilePreviewUIState.Error + } + } + } + + companion object { + private const val TAG = "ProfilePreviewViewModel" + + /** How long to wait on the relays before telling the user we came up empty. */ + val RELAY_LOOKUP_TIMEOUT = 20.seconds + + fun factory( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProfilePreviewViewModel( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = initialProfilePreviewUIState, + nostrRepository = nostrRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt new file mode 100644 index 00000000..73942a91 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt @@ -0,0 +1,27 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.Profile + +/** + * The person behind a pasted npub, before a chat with them is started. + * + * `Loaded` needs a profile read off a kind 0 -- `Profile.isResolved()` -- and never + * the "LOADING..." placeholder a pubkey gets on first sight, because the screen's one + * job is to show who this is. See docs/npub-profile-preview.md. + */ +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, and one a late answer can still overturn. + */ + data object NotFound : ProfilePreviewUIState + + /** The queue itself refused: nothing was asked, and asking again is the retry. */ + data object Error : ProfilePreviewUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt new file mode 100644 index 00000000..1cafb423 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/DirectMessagePeerSyncTest.kt @@ -0,0 +1,90 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What a direct message with one person asks the relays for, and whom it asks. + * + * Two request lists because two sets of relays hold two different things; the test + * pins each list's shape and the one relay the two sets share, so that a change to + * either relay set is noticed here rather than as a chat that never starts. + */ +class DirectMessagePeerSyncTest { + + private val alice = "b".repeat(64) + + // --- the DM relays: everything a chat needs ---------------------------------------- + + @Test + fun `one request per DM relay, asking for the three kinds a chat needs`() { + val requests = DirectMessagePeerSync.requests(alice) + + assertEquals(Relays.DefaultDMRelayList.size, requests.size) + assertEquals(Relays.DefaultDMRelayList.map { it.url }.sorted(), requests.map { it.relayURL }.sorted()) + + requests.forEach { request -> + assertEquals(DirectMessagePeerSync.PURPOSE, request.purpose) + // Level 0: somebody is waiting on this one. + assertEquals(0, request.level) + assertEquals( + listOf(MetadataEvent.KIND, KeyPackageEvent.KIND, ChatMessageRelayListEvent.KIND), + request.synchronizationFilter.kinds?.toList(), + ) + assertEquals(listOf(alice), request.synchronizationFilter.authors?.toList()) + // `limit = 1` across three kinds returned the kind 0 alone on the REQ fallback, + // and the key package never arrived. + assertNull(request.synchronizationFilter.limit) + } + } + + /** `computeId` buckets by minute, and the id is what lets a retry re-arm the row rather than add one. */ + @Test + fun `the id is the one computeId gives the relay and the filter`() { + DirectMessagePeerSync.requests(alice).forEach { request -> + assertEquals( + NegentropySynchronizeRequest.computeId(request.relayURL, request.synchronizationFilter), + request.id, + ) + } + } + + // --- the indexers: the kind 0 alone ------------------------------------------------- + + @Test + fun `one request per bootstrap relay, asking for the kind 0 alone`() { + val requests = DirectMessagePeerSync.profileRequests(alice) + + assertEquals(SignInSync.bootstrapRelays.size, requests.size) + assertEquals(SignInSync.bootstrapRelays.map { it.url }.sorted(), requests.map { it.relayURL }.sorted()) + + requests.forEach { request -> + assertEquals(DirectMessagePeerSync.PROFILE_PURPOSE, request.purpose) + assertEquals(0, request.level) + // A peer has no unsigned event of ours to hang off, unlike a sign-in. + assertNull(request.unsignedNostrEventId) + + val filter = request.synchronizationFilters.single() + assertEquals(listOf(MetadataEvent.KIND), filter.kinds?.toList()) + assertEquals(listOf(alice), filter.authors?.toList()) + } + } + + /** + * Our own relay is the one place both lists ask, on purpose: a profile created here + * is found here, and a chat can only be started here. Anything else the two share + * is a relay set that has drifted. + */ + @Test + fun `the two relay sets share our own relay and nothing else`() { + val dmRelays = DirectMessagePeerSync.requests(alice).map { it.relayURL }.toSet() + val profileRelays = DirectMessagePeerSync.profileRequests(alice).map { it.relayURL }.toSet() + + assertEquals(setOf(Relays.ephemeral.url), dmRelays intersect profileRelays) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt new file mode 100644 index 00000000..a319d8c3 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt @@ -0,0 +1,214 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * What the preview shows while the relays are asked, and when it stops waiting. + * + * The repository fake records every ask and holds the profile row in a + * `MutableStateFlow`, so the test can play the row arriving before the ask, after + * it, after the timeout, or never. The view model launches on `Dispatchers.IO`, as + * the others do, so the timeout is real time cut to a few hundred milliseconds + * through its constructor, and the assertions poll. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfilePreviewViewModelJvmTest { + + private val me = "a".repeat(64) + private val alice = "b".repeat(64) + + /** Records the asks; the profile flow is the row the device holds. */ + private class Recorder( + val profile: MutableStateFlow = MutableStateFlow(null), + private val refuse: Boolean = false, + ) : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + val effects = mutableListOf() + + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = profile + + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { + if (refuse) throw IllegalStateException("the queue is closed") + effects += "req:" + synchronizeNostrEventRequests.map { it.purpose }.distinct().joinToString() + } + + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { + if (refuse) throw IllegalStateException("the queue is closed") + effects += "neg:" + negentropySynchronizeRequests.map { it.purpose }.distinct().joinToString() + } + } + + private val bothAsks = listOf("req:${DirectMessagePeerSync.PROFILE_PURPOSE}", "neg:${DirectMessagePeerSync.PURPOSE}") + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(recorder: Recorder) = ProfilePreviewViewModel( + activeUserPublicKey = me, + profilePublicKey = alice, + initialProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository = recorder, + relayLookupTimeout = 300.milliseconds, + ) + + @Test + fun `initiating asks both sets of relays once, and waits`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + + assertEquals(bothAsks, recorder.effects) + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + + // A recomposition calling it again opens no second collect and asks nothing more. + viewModel.initiate() + delay(50) + assertEquals(bothAsks, recorder.effects) + } + + @Test + fun `a placeholder row is not a person`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(placeholder(alice))) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + delay(50) + + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + } + + /** Cached first: a person already known is on screen before any relay answers. */ + @Test + fun `a row already resolved loads at once, and the relays are still asked`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + + assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState) + await { recorder.effects.size == 2 } + assertEquals(bothAsks, recorder.effects) + } + + @Test + fun `a kind 0 arriving from the relays loads`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { recorder.effects.size == 2 } + recorder.profile.value = placeholder(alice) + recorder.profile.value = resolved(alice) + + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState) + + // Loaded is loaded: the timeout, had it still been armed, must not take it back. + delay(400) + assertIs(viewModel.profilePreviewUIState) + } + + @Test + fun `nothing arriving is not found, and a late kind 0 still loads`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + + recorder.profile.value = resolved(alice) + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + } + + @Test + fun `try again from not found asks both sets of relays again`() = runBlocking { + val recorder = Recorder() + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + + viewModel.retry() + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + await { recorder.effects.size == 4 } + assertEquals(bothAsks + bothAsks, recorder.effects) + + // And the clock was restarted with it. + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.NotFound } + } + + @Test + fun `a queue that refuses is an error, and try again asks again`() = runBlocking { + val recorder = Recorder(refuse = true) + val viewModel = viewModel(recorder) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error } + + // The timeout was cancelled with it: an error does not later become "not found". + delay(400) + assertEquals(ProfilePreviewUIState.Error, viewModel.profilePreviewUIState) + + viewModel.retry() + await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error } + } + + // --- fixtures ----------------------------------------------------------------------- + + private suspend fun await(condition: () -> Boolean) { + withTimeout(5_000) { + while (!condition()) delay(10) + } + } + + /** What every mint site writes: the "LOADING..." name, stamped `GENESIS_AT`. */ + private fun placeholder(publicKey: HexKey) = Profile( + publicKey = publicKey, + displayName = "LOADING...", + nostrEventId = "placeholder", + createdAt = GENESIS_AT, + ) + + private fun resolved(publicKey: HexKey) = Profile( + publicKey = publicKey, + displayName = "Alice", + nostrEventId = "kind0", + createdAt = Instant.fromEpochSeconds(1_700_000_000), + updatedAt = Instant.fromEpochSeconds(1_700_000_000), + savedAt = Instant.fromEpochSeconds(1_700_000_000), + ) +} From 39c056a2f2702dd7f2df509f20975b5906282b34 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:55:19 +0200 Subject: [PATCH 62/71] feat(ui): the profile preview screen, reachable by nothing yet Phase 2 of docs/npub-profile-preview.md. ProfilePreviewRoute is keyed by public key, because that is all the entrance has, and ProfilePreviewScreen draws the four states over a constant bar: the npub above the indicator while the relays are asked, the person -- avatar, name, nip05, the whole of the about, the npub that was pasted -- and under them "Start new chat", which hands over the route the dialog pushes today and creates nothing here. Not found is the sign-in screen's sentence with a try-again; a read-only identity is shown the person and nothing to do, and ReadOnlyEntrancesJvmTest gains the row. Registered in the host with the room replacing the preview, as it replaces the profile detail. No button navigates to it until Phase 3. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@8973c0058eefe8e7c9aae2f9642391cd16a32553 --- .../composeResources/values/strings.xml | 3 + .../ui/composable/ProfilePreviewScreen.kt | 282 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 26 ++ .../navigation/routes/ProfilePreviewRoute.kt | 18 ++ .../composable/ProfilePreviewScreenJvmTest.kt | 153 ++++++++++ .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 32 ++ 6 files changed, 514 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b74dcb34..c337db92 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -485,4 +485,7 @@ Sign in with a key Switch to it The profile you are in stays on this device. + + Looking for this profile on the relays. + Start new chat diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt new file mode 100644 index 00000000..85ea825c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt @@ -0,0 +1,282 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.looking_for_this_profile_on_the_relays +import mantra.composeapp.generated.resources.profile +import mantra.composeapp.generated.resources.start_new_chat +import mantra.composeapp.generated.resources.try_again +import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.Profile +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.ProfilePreviewViewModel +import press.mantra.compose.ui.view.state.ProfilePreviewUIState + +/** + * The person behind a pasted npub, and the one thing to do about them. + * + * Reached from the new-chat dialog by public key, before anything is created: the + * profile block is the confirmation the flow never had, and "Start new chat" hands + * over to `ChatRoomMessagingRoute` exactly as the dialog used to -- the room is made + * where it has always been made, and this screen is popped under it, as the profile + * detail is under a chat started from it. See docs/npub-profile-preview.md. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilePreviewScreen( + activeUserPublicKey: HexKey, + profilePublicKey: HexKey, + initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, + nostrRepository: NostrRepository, + onStartChat: (Route) -> Unit, + onNavigateBack: () -> Unit, +) { + val profilePreviewViewModel: ProfilePreviewViewModel = viewModel( + factory = ProfilePreviewViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = initialProfilePreviewUIState, + nostrRepository = nostrRepository, + ), + ) + + // Sixty-three characters of bech32, remembered once: they are what the user + // pasted, and the block is not a confirmation without them. + val npub = remember(profilePublicKey) { profilePublicKey.hexToNpubHrp() } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.profile)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + }, + ) { innerPadding -> + // The bar is constant, so the transition wraps the content alone. None of the + // branches needs `ColumnScope`, which is the one thing that costs. + ScreenStateTransition(profilePreviewViewModel.profilePreviewUIState) { uiState -> + when (val state = uiState) { + ProfilePreviewUIState.Loading -> { + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + Npub(npub) + LoadingDataIndicator( + fillScreen = false, + text = stringResource(Res.string.looking_for_this_profile_on_the_relays), + ) + } + } + + is ProfilePreviewUIState.Loaded -> { + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap), + ) { + ProfileBlock(profile = state.profile, publicKey = profilePublicKey, npub = npub) + + // A message is a gift wrap, sealed with the sender's key. Not for a + // read-only identity, which is shown the person and nothing to do. + if (LocalCanSign.current) { + StartChatButton( + onClick = { + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ) + ) + }, + ) + } + } + } + + ProfilePreviewUIState.NotFound -> { + // Nothing went wrong: the search came back empty, and that is the one + // absence this screen has to name. Asking again is the action. + EmptyState( + modifier = Modifier.padding(innerPadding).readableContent(), + message = stringResource(Res.string.we_could_not_find_a_profile_for_this_key), + icon = Icons.Default.PersonSearch, + action = { + TextButton(onClick = { profilePreviewViewModel.retry() }) { + Text(stringResource(Res.string.try_again)) + } + }, + ) + } + + ProfilePreviewUIState.Error -> { + // The queue refused, so nothing was asked; asking again is the retry. + ErrorState( + modifier = Modifier.padding(innerPadding).readableContent(), + onRetry = { profilePreviewViewModel.retry() }, + ) + } + } + } + } + + LaunchedEffect(true) { + if (initialProfilePreviewUIState == ProfilePreviewUIState.Loading) { + profilePreviewViewModel.initiate() + } + } +} + +/** + * Who this is: leading-aligned, because 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. + */ +@Composable +private fun ProfileBlock( + profile: Profile, + publicKey: HexKey, + npub: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + ) { + // 75dp is the avatar's size, a dimension and not a spacing, and is what the + // profile detail draws. + ProfileAvatar( + size = 75.dp, + publicKey = publicKey, + profile = profile, + ) + + Text( + text = profile.humanReadableNameOrPubkey(), + style = MaterialTheme.typography.titleLarge, + ) + + // The nip05 or the lightning address: the thing a user compares against what + // they were told. + profile.staticIdentifier()?.let { staticIdentifier -> + Text( + text = staticIdentifier, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // The whole of it. This is the one screen whose job is to let the user read it. + profile.about?.takeIf { it.isNotBlank() }?.let { about -> + Text( + text = about, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Npub(npub) + } +} + +@Composable +private fun Npub(npub: String) { + Text( + text = npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** + * A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite + * confirmation uses: Phase 4 of the plan disables this with a reason beside it, and a + * FAB has no disabled state in M3 or in the API. + */ +@Composable +private fun StartChatButton(onClick: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + ) { + Icon( + Icons.Default.Mail, + // The label is beside it. + contentDescription = Decorative, + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.start_new_chat)) + } +} + +@ConformancePreviews +@Composable +private fun ProfilePreviewScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProfilePreviewScreen( + activeUserPublicKey = "a".repeat(64), + profilePublicKey = "b".repeat(64), + initialProfilePreviewUIState = ProfilePreviewUIState.Loaded( + profile = Profile( + publicKey = "b".repeat(64), + displayName = "John Doe", + nip05 = "john@doe.com", + about = "I am the stone that the builder refused.", + nostrEventId = "nostrEventId", + ), + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + onStartChat = {}, + onNavigateBack = {}, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index dfd6fda4..4ba2210d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -86,8 +86,10 @@ import press.mantra.compose.ui.composable.navigation.routes.LandingRoute import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.ProfilesRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute +import press.mantra.compose.ui.composable.ProfilePreviewScreen import press.mantra.compose.ui.composable.ProfilesScreen import press.mantra.compose.ui.view.model.ProfilesViewModel import press.mantra.compose.ui.view.model.getAvailableWalletsMeta @@ -1450,6 +1452,30 @@ fun MantraNavHost( }, ) } + 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 + // above: 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() + }, + ) + } composable { backStackEntry -> val route: ImplementationPendingRoute = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt new file mode 100644 index 00000000..3b7a1dac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProfilePreviewRoute.kt @@ -0,0 +1,18 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.serialization.Serializable + +/** + * The person behind a pasted npub, before a chat with them is started. + * + * Keyed by public key rather than by a kind 0's event id, because that is all the + * entrance has -- and it is why the same screen will serve a scanned code or a + * `nostr:npub…` link. No relay hint: the dialog has none to give. + * See docs/npub-profile-preview.md. + */ +@Serializable +data class ProfilePreviewRoute( + val activeUserPublicKey: String, + val profilePublicKey: HexKey, +): Route() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt new file mode 100644 index 00000000..596b548c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt @@ -0,0 +1,153 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.LocalCanSign +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The person behind a pasted npub on their own screen: what is drawn in each state, + * what the button hands over, and who is offered it. + */ +@OptIn(ExperimentalTestApi::class) +class ProfilePreviewScreenJvmTest { + + private val activeUserPublicKey = "a".repeat(64) + private val profilePublicKey = "b".repeat(64) + private val npub = profilePublicKey.hexToNpubHrp() + + private val alice = Profile( + publicKey = profilePublicKey, + displayName = "Alice", + nip05 = "alice@example.com", + about = "Translating hard books into Sesotho, one chapter at a time.", + nostrEventId = "kind0", + ) + + @Test + fun `a found profile is drawn whole, with the npub that was pasted`() = render( + ProfilePreviewUIState.Loaded(profile = alice) + ) { + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("alice@example.com").assertIsDisplayed() + onNodeWithText("Translating hard books into Sesotho, one chapter at a time.").assertIsDisplayed() + onNodeWithText(npub).assertIsDisplayed() + } + + @Test + fun `starting a chat hands over the route the dialog used to push, and creates nothing here`() { + var opened: Route? = null + + render(ProfilePreviewUIState.Loaded(profile = alice), onStartChat = { opened = it }) { + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed().performClick() + } + + assertEquals( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ), + opened, + ) + } + + /** A message is a gift wrap sealed with the sender's key; a read-only identity is shown the person and nothing to do. */ + @Test + fun `a read-only identity sees the person and no button`() = render( + ProfilePreviewUIState.Loaded(profile = alice), + canSign = false, + ) { + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + /** Arriving loading is the one case that runs `initiate`, so this render is given relays that never answer. */ + @Test + fun `while the relays are asked the npub is already on screen`() = render( + ProfilePreviewUIState.Loading, + nostrRepository = NothingArrives, + ) { + onNodeWithText(npub).assertIsDisplayed() + onNodeWithText("Looking for this profile on the relays.").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `nothing found says so and offers to ask again`() = render( + ProfilePreviewUIState.NotFound + ) { + onNodeWithText("We could not find a profile for this key on the relays we asked.").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + + @Test + fun `a queue that refused is an error with a retry`() = render( + ProfilePreviewUIState.Error + ) { + onNodeWithText("Something went wrong").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + } + + /** No row on the device, and asks that go nowhere: the screen stays loading for as long as it is looked at. */ + private val NothingArrives = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithPublicKey(publicKey: String): Flow = flowOf(null) + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) = Unit + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) = Unit + } + + /** + * The screen at a phone's width. The state is passed in, so the NO_OP repository is + * never asked for anything: `initiate` only runs for a screen that arrives loading. + */ + private fun render( + uiState: ProfilePreviewUIState, + canSign: Boolean = true, + nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + onStartChat: (Route) -> Unit = {}, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 1200) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CompositionLocalProvider(LocalCanSign provides canSign) { + Box(modifier = Modifier.fillMaxSize()) { + ProfilePreviewScreen( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = profilePublicKey, + initialProfilePreviewUIState = uiState, + nostrRepository = nostrRepository, + onStartChat = onStartChat, + onNavigateBack = {}, + ) + } + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt index 5239ebb5..f59dd64d 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -44,6 +44,7 @@ import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.ActiveProfileUIState import press.mantra.compose.ui.view.state.HomeScreenUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState import kotlin.test.Test import kotlin.test.assertEquals @@ -93,6 +94,24 @@ class ReadOnlyEntrancesJvmTest { onNodeWithText("Pick a conversation to read it here.", useUnmergedTree = true).assertDoesNotExist() } + // --- Profile preview: the one thing to do about a person, or nothing --- + + @Test + fun `an identity that can sign is offered a chat with the person it looked up`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = true) { ProfilePreview() } } + + onNodeWithText("Reader", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun `a read-only identity is shown the person and offered nothing`() = runDesktopComposeUiTest(400, 1200) { + setContent { AsIdentity(canSign = false) { ProfilePreview() } } + + onNodeWithText("Reader", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + } + // --- Profile: key packages and key recovery, or neither --- @Test @@ -266,6 +285,19 @@ class ReadOnlyEntrancesJvmTest { ) } + /** The person behind a pasted npub, already found: the state is passed in, so nothing is asked. */ + @Composable + private fun ProfilePreview() { + ProfilePreviewScreen( + activeUserPublicKey = publicKey, + profilePublicKey = publicKey, + initialProfilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile.profile), + nostrRepository = FixedProfile, + onStartChat = {}, + onNavigateBack = {}, + ) + } + @Composable private fun ActiveProfile(signOut: SignOutDependencies? = null) { ActiveProfileScreen( From 5ba888d9f9f583c7535af311c372158a354f0154 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:59:26 +0200 Subject: [PATCH 63/71] feat(ui): the new-chat dialog opens the person, and no longer starts the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of docs/npub-profile-preview.md, and the user-visible change: "Direct message via npub" now shows who this is before anything is created. The dialog's button reads "Find profile" and pushes ProfilePreviewRoute; the chat is started from there. The field keeps what it parsed rather than whether it parsed. Its check and its decode used to be two different functions, which is how "nostr:npub1…" passed the one and failed the other, closing the dialog with nothing to show for it; CredentialParser.npubOrNull is the sign-in decode exposed for an address field -- an nsec is null there, because a field asking whom to write to must never take a secret for an answer -- and the enabled button and the route it pushes read the same key. The nip05 branch still lands on the placeholder, and the test says so. "Start chat" goes with its only reader. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@923b59ec3211eb5074439e382b32dd0ca54ecfe9 --- .../composeResources/values/strings.xml | 2 +- .../compose/identity/CredentialParser.kt | 27 ++++- .../StartDirectMessageToNpubOrNip05Dialog.kt | 79 ++++++------ .../identity/CredentialParserJvmTest.kt | 24 ++++ ...DirectMessageToNpubOrNip05DialogJvmTest.kt | 114 ++++++++++++++++++ 5 files changed, 203 insertions(+), 43 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c337db92..3b45342b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -234,7 +234,6 @@ Source dialect Start Start a group chat - Start chat Start chat via npub or nip05 Start key ceremony Startup error @@ -486,6 +485,7 @@ Switch to it The profile you are in stays on this device. + Find profile Looking for this profile on the relays. Start new chat diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt index b8560fde..33866cb1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt @@ -131,20 +131,39 @@ object CredentialParser { return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex())) } + /** + * The x-only key under an `npub1…`, with or without a `nostr:` prefix, or null. + * + * The one shape a message address can arrive in, for the field that takes one. Hex + * stays a secret here for the reason [parse] gives, and an nsec is null because it is + * not an address -- a field asking whom to write to must never take a secret for an + * answer. Same decode as the sign-in branch, so the two cannot disagree. + */ + fun npubOrNull(raw: String): HexKey? { + val token = raw.trim().removePrefix("nostr:").lowercase() + if (!token.startsWith("npub1")) return null + return decodeNpub(token) + } + + private fun npub(token: String): Result = + decodeNpub(token) + ?.let { Result.Recognised(SignInCredential.NostrPublicKey(it)) } + ?: Result.Refused(CredentialProblem.InvalidKey) + /** * Thirty-two bytes under the `npub` prefix that name a point on the curve -- the * counterpart of the `isValid()` a secret is checked with, since not every x * coordinate has a point above it. */ - private fun npub(token: String): Result { + private fun decodeNpub(token: String): HexKey? { val bytes = runCatching { val (hrp, data) = Bech32.decodeBytes(token) require(hrp == "npub") { "not an npub" } require(data.size == 32) { "not an x-only key" } data - }.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey) + }.getOrNull() ?: return null val xOnly = XonlyPublicKey(ByteVector32(bytes)) - if (!xOnly.publicKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) - return Result.Recognised(SignInCredential.NostrPublicKey(xOnly.value.toHex())) + if (!xOnly.publicKey.isValid()) return null + return xOnly.value.toHex() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt index 9fe197c1..521d0a9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Start +import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,9 +32,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import press.mantra.compose.extensions.bech32ToHexOrNull -import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.identity.CredentialParser import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.theme.MantraTheme import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -47,11 +47,22 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.find_profile import mantra.composeapp.generated.resources.input_npub_or_nip05 -import mantra.composeapp.generated.resources.start_chat import mantra.composeapp.generated.resources.start_chat_via_npub_or_nip05 +import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.theme.ConformancePreviews +/** + * Whom to write to, as an npub or a nip05. + * + * An npub no longer starts a chat from here: it opens the person's profile, and the + * chat is started from there once the user has seen who it is. The field keeps what + * it parsed rather than whether it parsed -- the enabled button and the route it + * pushes read the same decoded key, which is what closed the gap where `nostr:npub1…` + * passed the check and then failed the decode in silence. A nip05 still lands on the + * placeholder; resolving one is out of scope. See docs/npub-profile-preview.md. + */ @Composable fun StartDirectMessageToNpubOrNip05Dialog( activeUserPublicKey: HexKey, @@ -65,7 +76,10 @@ fun StartDirectMessageToNpubOrNip05Dialog( } ) { - val startChatButtonEnabled = remember { mutableStateOf(false) } + // The key under a pasted npub, or null while the field holds anything else. + val parsedPublicKey = remember { mutableStateOf(null) } + // A nip05 is recognised by shape alone; nothing resolves it yet. + val emailLikeAddressEntered = remember { mutableStateOf(false) } Card( modifier = Modifier @@ -123,63 +137,52 @@ fun StartDirectMessageToNpubOrNip05Dialog( }, lineLimits = TextFieldLineLimits.SingleLine, inputTransformation = { - val input = asCharSequence().toString().lowercase() + val input = asCharSequence().toString() - if (input.startsWith("npub1") || input.startsWith("nostr:npub1")) { - // Check for valid npub - startChatButtonEnabled.value = true - } else if (Parser.parseEmailLikeAddress(input) != null) { - // Check for valid npub - startChatButtonEnabled.value = true - } else { - startChatButtonEnabled.value = false - } + parsedPublicKey.value = CredentialParser.npubOrNull(input) + emailLikeAddressEntered.value = parsedPublicKey.value == null && + Parser.parseEmailLikeAddress(input.lowercase()) != null } ) TextButton( - enabled = startChatButtonEnabled.value, + enabled = parsedPublicKey.value != null || emailLikeAddressEntered.value, onClick = { + // Read before the dialog closes: the state lives in it. + val publicKey = parsedPublicKey.value + val input = textFieldState.text.toString() + scope.launch { toggleOpenDialogSetting.invoke(false) }.invokeOnCompletion { - val input = textFieldState.text.toString() - - val emailLikeAddress = Parser.parseEmailLikeAddress(input) - - if (emailLikeAddress != null) { - // TODO: Get public Key from hex... + if (publicKey != null) { onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Direct Message to ${textFieldState.text}" + ProfilePreviewRoute( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = publicKey, ) ) } else { - val publicKey = input.bech32ToHexOrNull() - - if (publicKey != null) { - onNavigateToRoute.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = publicKey, - relayHint = null - ) + // TODO: Resolve the nip05 to a public key, then the same route. + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Direct Message to $input" ) - } + ) } - } } ) { - Text(stringResource(Res.string.start_chat)) + Text(stringResource(Res.string.find_profile)) Spacer( modifier = Modifier.width(MaterialTheme.spacing.space125) ) Icon( - Icons.Default.Start, - contentDescription = "Start chat" + Icons.Default.PersonSearch, + // The label is beside it. + contentDescription = Decorative ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt index fd444982..d661009a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt @@ -5,6 +5,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotEquals +import kotlin.test.assertNull /** * Every row of the sign-in screen's table, in and out. @@ -114,4 +115,27 @@ class CredentialParserJvmTest { assertEquals(CredentialProblem.InvalidKey, refused("0".repeat(64))) assertEquals(CredentialProblem.InvalidKey, refused("f".repeat(64))) } + + // --- the address form: an npub or nothing --- + + /** The three spellings the sign-in accepts, plus the whitespace a paste brings. */ + @Test + fun `an address is an npub in any of its spellings`() { + for (input in listOf(npub, "nostr:$npub", npub.uppercase(), " $npub\n")) { + assertEquals(publicKey, CredentialParser.npubOrNull(input), "for '$input'") + } + } + + /** + * A field asking whom to write to must never take a secret for an answer, and it + * takes nothing else either: the hex the sign-in reads as a secret, an nprofile's + * TLV, and an npub with a bad checksum are all nothing here. + */ + @Test + fun `an address is not a secret, a hex, an nprofile or a broken npub`() { + val nprofile = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p" + for (input in listOf(nsec, "nostr:$nsec", hex, publicKey, nprofile, npub.dropLast(1) + "q", "", " ")) { + assertNull(CredentialParser.npubOrNull(input), "for '$input'") + } + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt new file mode 100644 index 00000000..d29710c6 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt @@ -0,0 +1,114 @@ +package press.mantra.compose.ui.composable.widgets.dialogs + +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What the new-chat dialog does with what is pasted into it. + * + * The button and the route read one decoded key, so the case that used to close the + * dialog and do nothing -- `nostr:npub1…`, which passed the check and failed the + * decode -- is the row worth having. The nip05 row pins where that branch still + * lands, so that it is recorded rather than assumed. + */ +@OptIn(ExperimentalTestApi::class) +class StartDirectMessageToNpubOrNip05DialogJvmTest { + + private val activeUserPublicKey = "a".repeat(64) + private val npub = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu" + private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + private val nsec = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp" + + @Test + fun `a pasted npub opens the person's profile, and starts no chat`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Find profile").assertIsNotEnabled() + onNodeWithText("Input npub... or nip05").performTextInput(npub) + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = publicKey), opened) + } + + /** The row that failed before: accepted by the field's check, refused by the decode, and nothing happened. */ + @Test + fun `a nostr-prefixed npub is the same person`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput("nostr:$npub") + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = publicKey), opened) + } + + /** A field asking whom to write to must never take a secret for an answer. */ + @Test + fun `an nsec, or an npub with a broken checksum, enables nothing`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput(nsec) + onNodeWithText("Find profile").assertIsNotEnabled() + } + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput(npub.dropLast(1) + "q") + onNodeWithText("Find profile").assertIsNotEnabled() + } + + assertNull(opened) + } + + /** Recognised by shape alone, and still the placeholder: resolving one is out of scope. */ + @Test + fun `a nip05 still lands on the placeholder`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput("alice@example.com") + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ImplementationPendingRoute("Direct Message to alice@example.com"), opened) + } + + private fun render( + onNavigateToRoute: (Route) -> Unit, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 800) { + setContent { + MantraTheme { + ProvideSnackbarHost { + StartDirectMessageToNpubOrNip05Dialog( + activeUserPublicKey = activeUserPublicKey, + scope = rememberCoroutineScope(), + toggleOpenDialogSetting = {}, + onNavigateToRoute = onNavigateToRoute, + ) + } + } + } + + assertions() + // The navigation runs when the close-the-dialog job completes; give it the frame. + waitForIdle() + } +} From 2adc5036131809fa1014d1e282a7c1f33b767908 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 13:19:51 +0200 Subject: [PATCH 64/71] feat(chat): the preview's button knows whether it can be pressed Phase 4 of docs/npub-profile-preview.md. Phases 1-3 moved the twenty-second dead end one screen later: a person found on an indexer, a press, and InitiatingNewChat waiting for a key package that someone who has never opened Curare will never have published. The preview now reads the answer before the press. A room that already exists opens; a key package that is here enables "Start new chat"; one that is not, once the relays have had their time, disables it with the reason under it and a try-again that asks the DM relays alone. The existing-room check was a loop with a TODO inside initiateNewChat; it is ChatRepository.findDirectMessageRoom now -- a room whose members are exactly the two keys, or the one key when they are the same -- read by both screens and pinned by a test on an in-memory database. The old size-one branch would have opened any room the peer sat in alone, whoever they were. Both halves are observed together, as the chat screen does: a key package arriving after the profile touches a different table, and a collect on the profile alone would never see it. A room is read once up front, because a key package is meant to be used once and the relays need not hold a fresh one for someone you already talk to. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@d6490985049b6bbf16e45021cbb9540c794428c2 --- .../composeResources/values/strings.xml | 3 + .../repository/DatabaseChatRepository.kt | 12 ++ .../compose/repository/ChatRepository.kt | 14 ++ .../ui/composable/ProfilePreviewScreen.kt | 118 ++++++++++-- .../ui/composable/navigation/MantraNavHost.kt | 1 + .../view/model/ChatRoomMessagingViewModel.kt | 53 ++---- .../ui/view/model/ProfilePreviewViewModel.kt | 114 ++++++++--- .../ui/view/state/ProfilePreviewUIState.kt | 25 +++ .../FindDirectMessageRoomJvmTest.kt | 129 +++++++++++++ .../composable/ProfilePreviewScreenJvmTest.kt | 74 +++++++- .../ui/composable/ReadOnlyEntrancesJvmTest.kt | 6 +- .../model/ProfilePreviewViewModelJvmTest.kt | 179 +++++++++++++++--- 12 files changed, 619 insertions(+), 109 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3b45342b..45be57b2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -488,4 +488,7 @@ Find profile Looking for this profile on the relays. Start new chat + You already have a chat with %1$s. + Checking whether %1$s can receive messages here. + %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index d0143ec3..6b71b5e8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -157,6 +157,18 @@ class DatabaseChatRepository( override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { return database.participantDao().findParticipantByPublicKey(publicKey) } + + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? { + val theTwoOfUs = setOf(userPublicKey, peerPublicKey) + + for (participant in database.participantDao().findParticipantByPublicKey(peerPublicKey)) { + val localChatRoom = database.chatRoomDao().findChatRoomById(participant.chatRoomId) ?: continue + val members = localChatRoom.localParticipants.map { it.participant.participantPublicKey }.toSet() + if (members == theTwoOfUs) return localChatRoom + } + + return null + } override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List { return database.chatMessageDao().getChatMessagesByChatRoomId(chatRoomId) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 9ea861ce..42794c40 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -116,6 +116,16 @@ interface ChatRepository { suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List + /** + * The room that is a conversation between [userPublicKey] and [peerPublicKey] + * alone, if there is one: its members are exactly the two of them, or the one of + * them 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 `ChatRoomMessagingViewModel.initiateNewChat` has applied since it was + * written, in one place with its name on it. See docs/npub-profile-preview.md. + */ + suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? + suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> @@ -300,6 +310,10 @@ interface ChatRepository { TODO("Not yet implemented") } + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? { + TODO("Not yet implemented") + } + override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List { return emptyList() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt index 85ea825c..27d0cc46 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt @@ -2,6 +2,7 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -14,6 +15,8 @@ import androidx.compose.material.icons.filled.Mail import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -25,19 +28,25 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.checking_whether_s_can_receive_messages_here import mantra.composeapp.generated.resources.looking_for_this_profile_on_the_relays +import mantra.composeapp.generated.resources.open_chat import mantra.composeapp.generated.resources.profile +import mantra.composeapp.generated.resources.s_hasn_t_set_up_messaging_on_mantra_yet import mantra.composeapp.generated.resources.start_new_chat import mantra.composeapp.generated.resources.try_again import mantra.composeapp.generated.resources.we_could_not_find_a_profile_for_this_key +import mantra.composeapp.generated.resources.you_already_have_a_chat_with_s import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.Profile import press.mantra.compose.extensions.hexToNpubHrp +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.Route @@ -56,6 +65,7 @@ import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.view.model.ProfilePreviewViewModel import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness /** * The person behind a pasted npub, and the one thing to do about them. @@ -64,15 +74,20 @@ import press.mantra.compose.ui.view.state.ProfilePreviewUIState * profile block is the confirmation the flow never had, and "Start new chat" hands * over to `ChatRoomMessagingRoute` exactly as the dialog used to -- the room is made * where it has always been made, and this screen is popped under it, as the profile - * detail is under a chat started from it. See docs/npub-profile-preview.md. + * detail is under a chat started from it. + * + * The button reads the state's readiness: a chat that exists opens, one that can be + * started starts, and one that cannot yet says why under a disabled button rather + * than twenty seconds into the next screen. See docs/npub-profile-preview.md. */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfilePreviewScreen( activeUserPublicKey: HexKey, profilePublicKey: HexKey, initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, nostrRepository: NostrRepository, + chatRepository: ChatRepository, onStartChat: (Route) -> Unit, onNavigateBack: () -> Unit, ) { @@ -82,6 +97,7 @@ fun ProfilePreviewScreen( profilePublicKey = profilePublicKey, initialProfilePreviewUIState = initialProfilePreviewUIState, nostrRepository = nostrRepository, + chatRepository = chatRepository, ), ) @@ -128,8 +144,19 @@ fun ProfilePreviewScreen( // A message is a gift wrap, sealed with the sender's key. Not for a // read-only identity, which is shown the person and nothing to do. if (LocalCanSign.current) { - StartChatButton( - onClick = { + ChatAction( + name = state.profile.humanReadableNameOrPubkey(), + readiness = state.readiness, + onOpenChat = { chatRoomId, relayHint -> + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + ) + ) + }, + onStartChat = { onStartChat( ChatRoomMessagingRoute( activeUserPublicKey = activeUserPublicKey, @@ -138,6 +165,7 @@ fun ProfilePreviewScreen( ) ) }, + onRetry = { profilePreviewViewModel.retry() }, ) } } @@ -236,26 +264,82 @@ private fun Npub(npub: String) { } /** + * The one thing to do about the person, and whether it can be done yet. + * * A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite - * confirmation uses: Phase 4 of the plan disables this with a reason beside it, and a - * FAB has no disabled state in M3 or in the API. + * confirmation uses, because a FAB has no disabled state in M3 or in the API, and a + * disabled button with the reason under it is what the action needs while the relays + * are still answering: 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. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun StartChatButton(onClick: () -> Unit) { - Button( +private fun ChatAction( + name: String, + readiness: Readiness, + onOpenChat: (chatRoomId: String, relayHint: String?) -> Unit, + onStartChat: () -> Unit, + onRetry: () -> Unit, +) { + Column( modifier = Modifier.fillMaxWidth(), - onClick = onClick, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), ) { - Icon( - Icons.Default.Mail, - // The label is beside it. - contentDescription = Decorative, - ) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) - Text(stringResource(Res.string.start_new_chat)) + Button( + modifier = Modifier.fillMaxWidth(), + enabled = readiness is Readiness.ExistingChat || readiness == Readiness.CanStartChat, + onClick = { + when (readiness) { + is Readiness.ExistingChat -> onOpenChat(readiness.chatRoomId, readiness.relayHint) + else -> onStartChat() + } + }, + ) { + Icon( + Icons.Default.Mail, + // The label is beside it. + contentDescription = Decorative, + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + Text( + stringResource( + if (readiness is Readiness.ExistingChat) Res.string.open_chat else Res.string.start_new_chat + ) + ) + } + + // A supporting line, not a second message. + when (readiness) { + is Readiness.ExistingChat -> SupportingLine(stringResource(Res.string.you_already_have_a_chat_with_s, name)) + Readiness.CanStartChat -> Unit + Readiness.Checking -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + ) { + LoadingIndicator() + SupportingLine(stringResource(Res.string.checking_whether_s_can_receive_messages_here, name)) + } + } + Readiness.NotYetOnMantra -> { + SupportingLine(stringResource(Res.string.s_hasn_t_set_up_messaging_on_mantra_yet, name)) + TextButton(onClick = onRetry) { + Text(stringResource(Res.string.try_again)) + } + } + } } } +@Composable +private fun SupportingLine(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + @ConformancePreviews @Composable private fun ProfilePreviewScreenPreview() { @@ -272,8 +356,10 @@ private fun ProfilePreviewScreenPreview() { about = "I am the stone that the builder refused.", nostrEventId = "nostrEventId", ), + readiness = Readiness.CanStartChat, ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, onStartChat = {}, onNavigateBack = {}, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 4ba2210d..92f022d0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -1459,6 +1459,7 @@ fun MantraNavHost( activeUserPublicKey = route.activeUserPublicKey, profilePublicKey = route.profilePublicKey, nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, // The room replaces the preview, as it replaces the profile detail // above: back from a chat is the list, not the person it was started // from. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt index 37fce883..a0d00b5e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomMessagingViewModel.kt @@ -67,44 +67,23 @@ class ChatRoomMessagingViewModel( // Get ChatRoomById viewModelScope.launch(Dispatchers.IO) { - // Check if we have a chat with activeUserPublicKey and chatRoomId (pubkey) as participants - val participants = chatRepository.getAllParticipantsWithPubKey(chatRoomId) - - participants.forEach { participant -> - chatRepository.getChatRoomByIdentifier(participant.chatRoomId)?.let { localChatRoom -> - // TODO: Check that chatRoom is not a group chat... - if (localChatRoom.localParticipants.size == 1) { - // This is a chat with self... - // TODO: navigateToTheChat... - viewModelScope.launch(Dispatchers.Main) { - onNavigateToChat.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = localChatRoom.chatRoom.id, - relayHint = relayHint - ) - ) - } - return@launch - - } else if (localChatRoom.localParticipants.size == 2) { - val activeUserParticipant = localChatRoom.localParticipants.find { localParticipant -> localParticipant.participant.participantPublicKey == activeUserPublicKey } - - if (activeUserParticipant != null) { - // We have found the chat we are looking for... - viewModelScope.launch(Dispatchers.Main) { - onNavigateToChat.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = localChatRoom.chatRoom.id, - relayHint = relayHint - ) - ) - } - return@launch - } - } + // A conversation with this person that already exists is the one to open. The + // rule -- the two of us and nobody else, or yourself alone -- is the + // repository's now, and the profile preview reads the same one. + chatRepository.findDirectMessageRoom( + userPublicKey = activeUserPublicKey, + peerPublicKey = chatRoomId + )?.let { localChatRoom -> + viewModelScope.launch(Dispatchers.Main) { + onNavigateToChat.invoke( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = localChatRoom.chatRoom.id, + relayHint = relayHint + ) + ) } + return@launch } // Observe BOTH halves of what we need. Watching only the profile meant a key diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt index f7b299a5..3c24ff6f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModel.kt @@ -14,34 +14,47 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import press.mantra.compose.database.model.MarmotKeyPackage +import press.mantra.compose.database.model.Profile import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness +import kotlin.concurrent.Volatile import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** - * Finds the person behind a pasted npub, and says when it has stopped looking. + * Finds the person behind a pasted npub, and says whether a chat with them can start. * - * Cached first: the observed row is whatever the device already holds, so a person + * Cached first: the observed rows are whatever the device already holds, so a person * already known is on screen before any relay answers, and the relays refresh them * behind it. The relays are asked twice over -- the kind 0 of the indexers, which hold * everyone's, and the three kinds a chat needs of the DM relays -- through * [DirectMessagePeerSync], so that pressing the button on the screen after this one * finds the key package already here. * - * The timeout is the one both DM view models use, and it only changes what is shown: - * the collect stays live past it, so a kind 0 that arrives on the twenty-first second - * still loads. See docs/npub-profile-preview.md, Phase 1. + * Both halves are observed together, as `ChatRoomMessagingViewModel` does, because a + * key package arriving after the profile touches a different table and a collect on + * the profile alone would never see it. A room that already exists is read once, up + * front: 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. + * + * One timeout, the one both DM view models use, and it only changes what is shown: + * nothing found becomes not-found, and a person found without a key package becomes + * not-yet-on-Mantra. The collect stays live past it, so whatever arrives on the + * twenty-first second still lands. See docs/npub-profile-preview.md. */ class ProfilePreviewViewModel( val activeUserPublicKey: HexKey, val profilePublicKey: HexKey, initialProfilePreviewUIState: ProfilePreviewUIState, private val nostrRepository: NostrRepository, + private val chatRepository: ChatRepository, /** How long the relays get before the screen says it came up empty. A parameter so a test need not wait it out. */ private val relayLookupTimeout: Duration = RELAY_LOOKUP_TIMEOUT, ) : ViewModel() { @@ -54,49 +67,104 @@ class ProfilePreviewViewModel( private var started = false private var lookupTimeout: Job? = null + /** Set when the timeout fires; cleared by a retry. Written and read on different coroutines. */ + @Volatile + private var relaysHadTheirTime = false + /** Once. A recomposition that calls this again must not open a second collect. */ fun initiate() { if (started) return started = true - observeProfile() + observe() askTheRelays() } - /** Asks again. The observation is still live, so the answer arrives through the same collect. */ + /** + * Asks again. The observation is still live, so the answer arrives through the same + * collect. With a person on screen it is the key package being asked for, and the + * DM relays alone are asked; without one, everything is. + */ fun retry() { - profilePreviewUIState = ProfilePreviewUIState.Loading - askTheRelays() + relaysHadTheirTime = false + when (val state = profilePreviewUIState) { + is ProfilePreviewUIState.Loaded -> { + if (state.readiness == Readiness.NotYetOnMantra) { + profilePreviewUIState = state.copy(readiness = Readiness.Checking) + } + askTheRelays(profileToo = false) + } + else -> { + profilePreviewUIState = ProfilePreviewUIState.Loading + askTheRelays(profileToo = true) + } + } } - private fun observeProfile() { + private fun observe() { viewModelScope.launch(Dispatchers.IO) { - nostrRepository.observeProfileWithPublicKey(profilePublicKey) + val existingRoom = chatRepository.findDirectMessageRoom( + userPublicKey = activeUserPublicKey, + peerPublicKey = profilePublicKey, + )?.let { localChatRoom -> + Readiness.ExistingChat( + chatRoomId = localChatRoom.chatRoom.id, + relayHint = localChatRoom.localParticipants + .firstOrNull { it.participant.participantPublicKey == profilePublicKey } + ?.participant?.relayHint, + ) + } + + combine( + nostrRepository.observeProfileWithPublicKey(profilePublicKey), + chatRepository.observeMarmotKeyPackageForPublicKey(profilePublicKey), + ) { profile, keyPackage -> // A placeholder is not a person. Mapping it to null here is what keeps // "LOADING..." off a screen whose one job is to say who this is. - .map { profile -> profile?.takeIf { it.isResolved() } } + profile?.takeIf { it.isResolved() } to keyPackage + } .distinctUntilChanged() - .collect { profile -> + .collect { (profile, keyPackage) -> if (profile == null) return@collect - lookupTimeout?.cancel() - profilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile) + show(profile, keyPackage, existingRoom) } } } - private fun askTheRelays() { + private fun show(profile: Profile, keyPackage: MarmotKeyPackage?, existingRoom: Readiness.ExistingChat?) { + val readiness = when { + existingRoom != null -> existingRoom + keyPackage != null -> Readiness.CanStartChat + relaysHadTheirTime -> Readiness.NotYetOnMantra + else -> Readiness.Checking + } + // The clock stops only when there is nothing left to wait for. A profile on its + // own cancels nothing: the key package is still owed. + if (readiness != Readiness.Checking) lookupTimeout?.cancel() + profilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile, readiness = readiness) + } + + private fun askTheRelays(profileToo: Boolean = true) { lookupTimeout?.cancel() lookupTimeout = viewModelScope.launch(Dispatchers.IO) { delay(relayLookupTimeout) - if (profilePreviewUIState is ProfilePreviewUIState.Loading) { - profilePreviewUIState = ProfilePreviewUIState.NotFound + relaysHadTheirTime = true + when (val state = profilePreviewUIState) { + ProfilePreviewUIState.Loading -> profilePreviewUIState = ProfilePreviewUIState.NotFound + is ProfilePreviewUIState.Loaded -> + if (state.readiness == Readiness.Checking) { + profilePreviewUIState = state.copy(readiness = Readiness.NotYetOnMantra) + } + else -> Unit } } viewModelScope.launch(Dispatchers.IO) { try { - nostrRepository.queueSynchronizeNostrEvent( - DirectMessagePeerSync.profileRequests(profilePublicKey) - ) + if (profileToo) { + nostrRepository.queueSynchronizeNostrEvent( + DirectMessagePeerSync.profileRequests(profilePublicKey) + ) + } nostrRepository.queueNegentropySynchronizeRequest( DirectMessagePeerSync.requests(profilePublicKey) ) @@ -119,6 +187,7 @@ class ProfilePreviewViewModel( profilePublicKey: HexKey, initialProfilePreviewUIState: ProfilePreviewUIState = ProfilePreviewUIState.Loading, nostrRepository: NostrRepository, + chatRepository: ChatRepository, ): ViewModelProvider.Factory = viewModelFactory { initializer { ProfilePreviewViewModel( @@ -126,6 +195,7 @@ class ProfilePreviewViewModel( profilePublicKey = profilePublicKey, initialProfilePreviewUIState = initialProfilePreviewUIState, nostrRepository = nostrRepository, + chatRepository = chatRepository, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt index 73942a91..f61064f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProfilePreviewUIState.kt @@ -14,6 +14,7 @@ sealed interface ProfilePreviewUIState { data class Loaded( val profile: Profile, + val readiness: Readiness, ) : ProfilePreviewUIState /** @@ -24,4 +25,28 @@ sealed interface ProfilePreviewUIState { /** The queue itself refused: nothing was asked, and asking again is the retry. */ data object Error : ProfilePreviewUIState + + /** + * Whether pressing the button would get anywhere, read here rather than twenty + * seconds into the next screen. + * + * A fact about a *loaded* profile: a room does not make a person known, and a + * member still "LOADING..." waits on their kind 0 here like anyone else. + */ + 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 + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt new file mode 100644 index 00000000..80cb7557 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/FindDirectMessageRoomJvmTest.kt @@ -0,0 +1,129 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.Profile +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What counts as a conversation between two keys. + * + * The rule `ChatRoomMessagingViewModel.initiateNewChat` applied inline since it was + * written -- the two of us and nobody else, or yourself alone -- now has a name and a + * test, because the profile preview reads it too and the two must agree. + */ +class FindDirectMessageRoomJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseChatRepository(db, scope) + + private val me = "a".repeat(64) + private val alice = "b".repeat(64) + private val bob = "c".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `a room with exactly the two of us is the conversation`() = runBlocking { + seedPeople(me, alice, bob) + seedRoom("group", me, alice, bob) + seedRoom("dm", me, alice) + + assertEquals("dm", repository.findDirectMessageRoom(me, alice)?.chatRoom?.id) + } + + /** A group of two is not told apart from a direct message; a group of three is not one. */ + @Test + fun `a room with a third member is not`() = runBlocking { + seedPeople(me, alice, bob) + seedRoom("group", me, alice, bob) + + assertNull(repository.findDirectMessageRoom(me, alice)) + } + + /** The peer in a room I am not in is not a conversation with me. */ + @Test + fun `a room the peer has with somebody else is not`() = runBlocking { + seedPeople(me, alice, bob) + seedRoom("theirs", alice, bob) + + assertNull(repository.findDirectMessageRoom(me, alice)) + } + + @Test + fun `a room with myself alone is the conversation with myself`() = runBlocking { + seedPeople(me, alice) + seedRoom("notes", me) + seedRoom("dm", me, alice) + + assertEquals("notes", repository.findDirectMessageRoom(me, me)?.chatRoom?.id) + assertEquals("dm", repository.findDirectMessageRoom(me, alice)?.chatRoom?.id) + } + + @Test + fun `nobody in any room is nothing`() = runBlocking { + seedPeople(me, alice) + + assertNull(repository.findDirectMessageRoom(me, alice)) + } + + // --- fixtures ----------------------------------------------------------------------- + + /** A participant row needs a profile, and a profile needs the event it was read off. */ + private suspend fun seedPeople(vararg publicKeys: HexKey) { + publicKeys.forEach { publicKey -> + val nostrEventId = publicKey.take(8).repeat(8) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert(Profile(publicKey = publicKey, userName = publicKey.take(4), nostrEventId = nostrEventId)) + } + } + + private suspend fun seedRoom(id: String, vararg members: HexKey) { + db.chatRoomDao().upsert( + ChatRoom( + id = id, + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = null, + ) + ) + db.participantDao().upsert( + members.map { publicKey -> + Participant( + participantPublicKey = publicKey, + chatRoomId = id, + relayHint = null, + ) + } + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt index 596b548c..0f1dc539 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreenJvmTest.kt @@ -7,14 +7,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.extensions.hexToNpubHrp import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute @@ -23,6 +28,7 @@ import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness import kotlin.test.Test import kotlin.test.assertEquals @@ -47,7 +53,7 @@ class ProfilePreviewScreenJvmTest { @Test fun `a found profile is drawn whole, with the npub that was pasted`() = render( - ProfilePreviewUIState.Loaded(profile = alice) + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat) ) { onNodeWithText("Alice").assertIsDisplayed() onNodeWithText("alice@example.com").assertIsDisplayed() @@ -59,7 +65,7 @@ class ProfilePreviewScreenJvmTest { fun `starting a chat hands over the route the dialog used to push, and creates nothing here`() { var opened: Route? = null - render(ProfilePreviewUIState.Loaded(profile = alice), onStartChat = { opened = it }) { + render(ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat), onStartChat = { opened = it }) { onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed().performClick() } @@ -76,7 +82,7 @@ class ProfilePreviewScreenJvmTest { /** A message is a gift wrap sealed with the sender's key; a read-only identity is shown the person and nothing to do. */ @Test fun `a read-only identity sees the person and no button`() = render( - ProfilePreviewUIState.Loaded(profile = alice), + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat), canSign = false, ) { onNodeWithText("Alice").assertIsDisplayed() @@ -84,10 +90,64 @@ class ProfilePreviewScreenJvmTest { } /** Arriving loading is the one case that runs `initiate`, so this render is given relays that never answer. */ + // --- whether the button can be pressed --- + + @Test + fun `a room that exists is opened, not started again`() { + var opened: Route? = null + + render( + ProfilePreviewUIState.Loaded( + profile = alice, + readiness = Readiness.ExistingChat(chatRoomId = "room", relayHint = "wss://hint.example"), + ), + onStartChat = { opened = it }, + ) { + onNodeWithText("You already have a chat with Alice.").assertIsDisplayed() + onNodeWithText("Start new chat", useUnmergedTree = true).assertDoesNotExist() + onNodeWithText("Open chat", useUnmergedTree = true).assertIsDisplayed().performClick() + } + + assertEquals( + ChatRoomMessagingRoute(activeUserPublicKey = activeUserPublicKey, chatRoomId = "room", relayHint = "wss://hint.example"), + opened, + ) + } + + @Test + fun `while the key package is still owed the button waits, and says so`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.Checking) + ) { + onNodeWithText("Start new chat", useUnmergedTree = true).assertIsDisplayed() + onNodeWithText("Start new chat").assertIsNotEnabled() + onNodeWithText("Checking whether Alice can receive messages here.").assertIsDisplayed() + } + + @Test + fun `a person not yet on Mantra cannot be written to, and the reason is under the button`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.NotYetOnMantra) + ) { + onNodeWithText("Start new chat").assertIsNotEnabled() + onNodeWithText("Alice hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + } + + @Test + fun `a person whose key package is here can be written to at once`() = render( + ProfilePreviewUIState.Loaded(profile = alice, readiness = Readiness.CanStartChat) + ) { + onNodeWithText("Start new chat").assertIsEnabled() + onNodeWithText("Checking whether", substring = true).assertDoesNotExist() + onNodeWithText("hasn't set up messaging", substring = true).assertDoesNotExist() + } + + // --- the other states --- + @Test fun `while the relays are asked the npub is already on screen`() = render( ProfilePreviewUIState.Loading, nostrRepository = NothingArrives, + chatRepository = NoRooms, ) { onNodeWithText(npub).assertIsDisplayed() onNodeWithText("Looking for this profile on the relays.").assertIsDisplayed() @@ -118,6 +178,12 @@ class ProfilePreviewScreenJvmTest { override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) = Unit } + /** No room with the person, and no key package for them. */ + private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: String): Flow = flowOf(null) + override suspend fun findDirectMessageRoom(userPublicKey: String, peerPublicKey: String): LocalChatRoom? = null + } + /** * The screen at a phone's width. The state is passed in, so the NO_OP repository is * never asked for anything: `initiate` only runs for a screen that arrives loading. @@ -126,6 +192,7 @@ class ProfilePreviewScreenJvmTest { uiState: ProfilePreviewUIState, canSign: Boolean = true, nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository: ChatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, onStartChat: (Route) -> Unit = {}, assertions: ComposeUiTest.() -> Unit, ) = runDesktopComposeUiTest(width = 400, height = 1200) { @@ -139,6 +206,7 @@ class ProfilePreviewScreenJvmTest { profilePublicKey = profilePublicKey, initialProfilePreviewUIState = uiState, nostrRepository = nostrRepository, + chatRepository = chatRepository, onStartChat = onStartChat, onNavigateBack = {}, ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt index f59dd64d..1cfab53d 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ReadOnlyEntrancesJvmTest.kt @@ -291,8 +291,12 @@ class ReadOnlyEntrancesJvmTest { ProfilePreviewScreen( activeUserPublicKey = publicKey, profilePublicKey = publicKey, - initialProfilePreviewUIState = ProfilePreviewUIState.Loaded(profile = profile.profile), + initialProfilePreviewUIState = ProfilePreviewUIState.Loaded( + profile = profile.profile, + readiness = ProfilePreviewUIState.Readiness.CanStartChat, + ), nostrRepository = FixedProfile, + chatRepository = NoRooms, onStartChat = {}, onNavigateBack = {}, ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt index a319d8c3..1d120e26 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProfilePreviewViewModelJvmTest.kt @@ -12,12 +12,19 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.NegentropySynchronizeRequest +import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant import press.mantra.compose.nostr.DirectMessagePeerSync +import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.view.state.ProfilePreviewUIState +import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -27,13 +34,14 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant /** - * What the preview shows while the relays are asked, and when it stops waiting. + * What the preview shows while the relays are asked, when it stops waiting, and + * whether the button it ends on can be pressed. * - * The repository fake records every ask and holds the profile row in a - * `MutableStateFlow`, so the test can play the row arriving before the ask, after - * it, after the timeout, or never. The view model launches on `Dispatchers.IO`, as - * the others do, so the timeout is real time cut to a few hundred milliseconds - * through its constructor, and the assertions poll. + * The repository fakes record every ask and hold the profile row and the key package + * in `MutableStateFlow`s, so the test can play them arriving in either order, after + * the timeout, or never. The view model launches on `Dispatchers.IO`, as the others + * do, so the timeout is real time cut to a few hundred milliseconds through its + * constructor, and the assertions poll. */ @OptIn(ExperimentalCoroutinesApi::class) class ProfilePreviewViewModelJvmTest { @@ -61,7 +69,18 @@ class ProfilePreviewViewModelJvmTest { } } - private val bothAsks = listOf("req:${DirectMessagePeerSync.PROFILE_PURPOSE}", "neg:${DirectMessagePeerSync.PURPOSE}") + /** The key package the device holds for the peer, and the room it already has with them. */ + private class Rooms( + val keyPackage: MutableStateFlow = MutableStateFlow(null), + private val existingRoom: LocalChatRoom? = null, + ) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow = keyPackage + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? = existingRoom + } + + private val profileAsk = "req:${DirectMessagePeerSync.PROFILE_PURPOSE}" + private val chatAsk = "neg:${DirectMessagePeerSync.PURPOSE}" + private val bothAsks = listOf(profileAsk, chatAsk) @BeforeTest fun setUp() { @@ -73,14 +92,17 @@ class ProfilePreviewViewModelJvmTest { Dispatchers.resetMain() } - private fun viewModel(recorder: Recorder) = ProfilePreviewViewModel( + private fun viewModel(recorder: Recorder, rooms: Rooms = Rooms()) = ProfilePreviewViewModel( activeUserPublicKey = me, profilePublicKey = alice, initialProfilePreviewUIState = ProfilePreviewUIState.Loading, nostrRepository = recorder, + chatRepository = rooms, relayLookupTimeout = 300.milliseconds, ) + // --- finding the person --------------------------------------------------------------- + @Test fun `initiating asks both sets of relays once, and waits`() = runBlocking { val recorder = Recorder() @@ -119,29 +141,11 @@ class ProfilePreviewViewModelJvmTest { viewModel.initiate() await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } - assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState) + assertEquals(loaded(Readiness.Checking), viewModel.profilePreviewUIState) await { recorder.effects.size == 2 } assertEquals(bothAsks, recorder.effects) } - @Test - fun `a kind 0 arriving from the relays loads`() = runBlocking { - val recorder = Recorder() - val viewModel = viewModel(recorder) - - viewModel.initiate() - await { recorder.effects.size == 2 } - recorder.profile.value = placeholder(alice) - recorder.profile.value = resolved(alice) - - await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } - assertEquals(ProfilePreviewUIState.Loaded(resolved(alice)), viewModel.profilePreviewUIState) - - // Loaded is loaded: the timeout, had it still been armed, must not take it back. - delay(400) - assertIs(viewModel.profilePreviewUIState) - } - @Test fun `nothing arriving is not found, and a late kind 0 still loads`() = runBlocking { val recorder = Recorder() @@ -187,6 +191,91 @@ class ProfilePreviewViewModelJvmTest { await { viewModel.profilePreviewUIState == ProfilePreviewUIState.Error } } + // --- whether the button can be pressed ----------------------------------------------- + + /** + * 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: without this row, someone you talk to + * every day would read "hasn't set up messaging". + */ + @Test + fun `a room that exists opens, key package or not`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val viewModel = viewModel(recorder, Rooms(existingRoom = roomWith(alice))) + + viewModel.initiate() + await { viewModel.profilePreviewUIState is ProfilePreviewUIState.Loaded } + + assertEquals(loaded(Readiness.ExistingChat(chatRoomId = "room", relayHint = "wss://hint.example")), viewModel.profilePreviewUIState) + + // Nothing left to wait for, so the timeout has nothing to say. + delay(400) + assertIs(assertIs(viewModel.profilePreviewUIState).readiness) + } + + @Test + fun `the profile first, then the key package, and the clock stops`() = runBlocking { + val recorder = Recorder() + val rooms = Rooms() + val viewModel = viewModel(recorder, rooms) + + viewModel.initiate() + recorder.profile.value = resolved(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.Checking) } + + rooms.keyPackage.value = keyPackage(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) } + + delay(400) + assertEquals(loaded(Readiness.CanStartChat), viewModel.profilePreviewUIState) + } + + @Test + fun `the key package first, then the profile, is ready at once`() = runBlocking { + val recorder = Recorder() + val rooms = Rooms(keyPackage = MutableStateFlow(keyPackage(alice))) + val viewModel = viewModel(recorder, rooms) + + viewModel.initiate() + delay(50) + assertEquals(ProfilePreviewUIState.Loading, viewModel.profilePreviewUIState) + + recorder.profile.value = resolved(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) } + } + + @Test + fun `a person with no key package is not yet on Mantra after the timeout, and a late one still counts`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val rooms = Rooms() + val viewModel = viewModel(recorder, rooms) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == loaded(Readiness.Checking) } + await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) } + + rooms.keyPackage.value = keyPackage(alice) + await { viewModel.profilePreviewUIState == loaded(Readiness.CanStartChat) } + } + + /** The person is found; it is the key package being asked for again, and the DM relays alone are asked. */ + @Test + fun `try again with a person on screen asks the DM relays alone and keeps the person`() = runBlocking { + val recorder = Recorder(profile = MutableStateFlow(resolved(alice))) + val viewModel = viewModel(recorder, Rooms()) + + viewModel.initiate() + await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) } + assertEquals(bothAsks, recorder.effects) + + viewModel.retry() + assertEquals(loaded(Readiness.Checking), viewModel.profilePreviewUIState) + await { recorder.effects.size == 3 } + assertEquals(bothAsks + chatAsk, recorder.effects) + + await { viewModel.profilePreviewUIState == loaded(Readiness.NotYetOnMantra) } + } + // --- fixtures ----------------------------------------------------------------------- private suspend fun await(condition: () -> Boolean) { @@ -195,6 +284,8 @@ class ProfilePreviewViewModelJvmTest { } } + private fun loaded(readiness: Readiness) = ProfilePreviewUIState.Loaded(profile = resolved(alice), readiness = readiness) + /** What every mint site writes: the "LOADING..." name, stamped `GENESIS_AT`. */ private fun placeholder(publicKey: HexKey) = Profile( publicKey = publicKey, @@ -203,12 +294,40 @@ class ProfilePreviewViewModelJvmTest { createdAt = GENESIS_AT, ) + private val at = Instant.fromEpochSeconds(1_700_000_000) + private fun resolved(publicKey: HexKey) = Profile( publicKey = publicKey, displayName = "Alice", nostrEventId = "kind0", - createdAt = Instant.fromEpochSeconds(1_700_000_000), - updatedAt = Instant.fromEpochSeconds(1_700_000_000), - savedAt = Instant.fromEpochSeconds(1_700_000_000), + createdAt = at, + updatedAt = at, + savedAt = at, + ) + + private fun keyPackage(publicKey: HexKey) = MarmotKeyPackage( + id = "keypackage", + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = ByteArray(0), + ) + + private fun roomWith(peer: HexKey) = LocalChatRoom( + chatRoom = ChatRoom( + id = "room", + userPublicKey = me, + subject = null, + description = null, + mlsGroupState = "state", + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = "room", + relayHint = "wss://hint.example", + ), + profile = null, + ) + }, ) } From a8ba589ecc497fca204cb2751162a6023737113a Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 13:19:51 +0200 Subject: [PATCH 65/71] docs: record what the profile preview plan built, and the places it chose differently Two strings that already existed, a room rule that follows the old code's intent rather than its branch, a retry that never takes an open chat back to checking, the flag the ordering cases asked for, and the test harness the other view models use. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@b3bcc06047cba0e77bbccee1406c5e66af51d01f --- docs/README.md | 6 +++--- docs/npub-profile-preview.md | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index 02bcfb50..eb82fa8c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,8 +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 +The npub profile preview note is a phased plan that has 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. +`ChatRoomMessagingViewModel.initiateNewChat` open, its four decisions before its +four phases, and its table of where the build chose differently first. diff --git a/docs/npub-profile-preview.md b/docs/npub-profile-preview.md index 2c8be310..848c417b 100644 --- a/docs/npub-profile-preview.md +++ b/docs/npub-profile-preview.md @@ -9,10 +9,28 @@ Read this with `HomeScreen.kt`, `StartDirectMessageToNpubOrNip05Dialog.kt` and 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 +**Built**, phases 1–4, 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. +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 | + +One thing found on the way that is in no phase: the dialog's `Dialog` renders inside +the desktop test root, so `onNodeWithText` finds its field and its button without a +popup matcher, and `performTextInput` drives the `inputTransformation` on every +edit — which is what made the `nostr:npub1…` row a four-line test. ## The flow today @@ -709,6 +727,10 @@ the same shape and got it wrong twice before it was right — the comments at li 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. +Phases 1–4 are now implemented, in one sitting and in order. The Phase 4 +uncertainty resolved as predicted: the ordering cases are where the flag in the +table above came from, and the screen was what was left. + ## Out of scope - **nip05.** The dialog's email-like branch still lands on `ImplementationPendingRoute`. From d7d6f5a9229baee792aa2795a6a8f6bd3a65fe6f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 16:35:31 +0200 Subject: [PATCH 66/71] chore(scripts): a partial pick, and the seven deletes the FAB sweep needs Two rules for the third pull. ca6c16be moves twenty screens onto one ExtendedFab; seven of them -- the curated-list and group-identity screens -- were taken out with lines B, D and H, so each is a modify/delete conflict that resolves as "still deleted", the DELETE rule it already had for one test file, now for a set. d2a4298d is half in line D and half in a line Mantra kept: its Broadcast-all button on the entries screen is gone here, but the sendToRelays it lifted into the broadcast view model's companion, the RelayOutcomeLabel widget and the screen reading it are the broadcast line Mantra holds. PARTIAL applies the diff restricted to the kept paths, commits it under the original message and trailer, and writes what was left out into the note, so the four shared files stay identical to the fork's and the FAB commit lands on them cleanly. Co-Authored-By: Claude Opus 5 --- docs/scripts/curated-replay.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/scripts/curated-replay.py b/docs/scripts/curated-replay.py index a1cfc686..bf6b209d 100755 --- a/docs/scripts/curated-replay.py +++ b/docs/scripts/curated-replay.py @@ -27,7 +27,12 @@ aborted, so a new conflict is a decision rather than a guess. What the rules enc compiled against the pin it recorded; `5efeae76` named a branch commit that no longer exists and is mapped to the master commit with the identical tree. * a handful of (commit, file) pairs where one side simply wins (THEIRS, DELETE), each - with the reason written into the commit. + with the reason written into the commit. DELETE is also how a sweep over screens + lands on a tree that no longer has some of them: the modify/delete conflict on each + removed screen resolves as "still deleted". + * a commit of which Mantra wants only some files (PARTIAL): the diff restricted to those + paths is applied and committed under the original message, and the note names what + was left out. For a commit half in a removed line and half in a kept one. Every resolution is appended to the landed commit's message as a paragraph, ahead of the trailers, so the branch explains itself. @@ -64,7 +69,24 @@ THEIRS_WHY = { 'e3cc23ae': 'the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it', '2f420230': "this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the seed derivation that import served; the file is identical on both sides afterwards (docs/curated-to-mantra-profiles.md, the third decision)", } -DELETE = {('55664cc7', 'composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt')} +DELETE = {('55664cc7', 'composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt')} | { + # the extended-FAB sweep touches twenty screens; these seven were taken out with lines B, D and H + ('ca6c16be', UI + f) for f in ( + 'AcceptCuratedSuggestionScreen.kt', 'AddGroupPostScreen.kt', 'EditGroupCuratedSchemaScreen.kt', + 'EditGroupNostrProfileScreen.kt', 'EditGroupRelaysScreen.kt', 'GroupCuratedEntriesScreen.kt', + 'ProposeGroupEventScreen.kt', + ) +} +VM = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/' +PARTIAL = { # orig -> (paths to take, why the rest stays out) + 'd2a4298d': ([ + 'composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt', + UI + 'BroadcastGroupSignedEventScreen.kt', + UI + 'widgets/RelayOutcomeLabel.kt', + VM + 'BroadcastGroupSignedEventViewModel.kt', + ], "only its broadcast half: sendToRelays lifted into the view model's companion, the RelayOutcomeLabel widget and the screen reading it, and the GroupSignedEvent comment. " + "The entries screen's Broadcast-all button, its view model and state, its tests, its five strings and its nav-host wiring are line D, which Mantra took out (docs/curated-to-mantra.md)"), +} def main(): @@ -113,6 +135,14 @@ def main(): for orig in args: o8 = orig[:8]; sha = rw(o8); subject = git('log', '-1', '--format=%s', sha).stdout.strip(); notes = [] + if o8 in PARTIAL: + paths, why = PARTIAL[o8] + patch = git('diff', '--binary', f'{sha}^', sha, '--', *paths).stdout + r = subprocess.run(['git', 'apply', '--index', '--3way'], cwd=wt, input=patch, capture_output=True, text=True) + if r.returncode != 0: sys.exit(f'STUCK at {o8} {subject}: the partial pick did not apply: {r.stderr.strip()[:300]}') + git('commit', '-q', '-C', sha) + amend_note(f'Replayed onto Mantra by docs/curated-to-mantra.md, in part: {why}.') + print(f"{o8} partial {subject[:80]}"); continue r = git('-c', 'merge.conflictStyle=diff3', 'cherry-pick', '--allow-empty', sha, check=False) if r.returncode != 0: stuck = [] From b3379f8117f882810427d5853c28ac4bf43aa3e0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 13:31:32 +0200 Subject: [PATCH 67/71] feat(groups): broadcast every accepted entry from one button, each to where its own would go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entries screen had one "Broadcast" under each card, and each opened `BroadcastGroupSignedEventScreen` for that one entry: a group that has accepted twenty entries into its lists sent them with twenty trips through the relay picker. This is the other button. "Broadcast all", an extended FAB in a bottom bar, sends every entry on the screen, newest first, one after another, and says under each card what its relays answered. Behind no gate, like the button under each card: sending what the group has already signed takes no share of the key. **Each entry goes where its own broadcast screen would have started.** The list that screen seeds -- `defaultRelaysFor`: the group's General write relays or the app's publish set, plus the list's own relays, minus what the group has blocked -- is what a member who reads nothing and presses the button sends to, and this button is that member with twenty entries. The group's relay lists are read into the loaded state for it; the list's relays come off the entry, which carries its schema, so the rule is handed that one schema rather than all of the group's. Nothing is shown before the press, which is the difference from the one-event screen and the reason that screen stays: a refusal is answered there, where the relay can be changed and the send tried again. **One entry at a time, on purpose.** The rows fill in top to bottom, so the member can see where the batch is; and a public relay sent twenty events in one burst answers "rate-limited" to most of them, which would make the button worse than the presses it saves. Each entry's send has the same ceiling as a single broadcast and the batch has no other, since a long list is meant to take a while. It goes to the pool directly rather than through the queue, for the reason `BroadcastGroupSignedEventViewModel` gives, and it is only as durable as the press: leaving the screen ends it where it is. **The send is shared rather than copied.** What a relay's answer becomes -- an OK, a rejection in the relay's words, a failure, or no answer when the whole send runs out of time -- was decided inside `broadcast()` on the one-event view model. It is now `sendToRelays` on that model's companion, beside `defaultRelaysFor` and `wireEventOf`, and `broadcast()` keeps only the state around it; the thirteen tests on it pass unchanged. The words for an answer moved the same way, out of the relay row and into `RelayOutcome.label()`, which both screens now use. **What the member is told.** A line above the cards -- "Sending entry 3 of 12…", then "Accepted by every relay for 11 of 12 entries." -- persistent rather than a snackbar, because it is the answer the member came for and a snackbar would be gone before the last card had been read. Under each card, "Accepted by n of m relays." in the one-event screen's words, and one line per relay that did not say OK, named with its reason, because "blocked: not on the allow list" is something a member can act on and a count of two out of three is not. An entry with nowhere to go -- everything the seed would name blocked -- is marked so rather than sent to nobody. The FAB looks disabled while a batch is out, and a second press is refused. `GroupCuratedEntriesViewModelJvmTest` pins where each entry goes, that the next waits for the last to settle, what the answers become, and the refusals; the screen test drives the button end to end and checks that each answer sits between its card and that card's own broadcast. The shared fixture entry now carries the `a` root a real entry has -- without it the seed rule could not find the list's relays, which the first run of these tests found. Verified with :composeApp:m3Audit (all budgets met), :composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest (1085 tests, 7 new). Replayed onto Mantra by docs/curated-to-mantra.md, in part: only its broadcast half: sendToRelays lifted into the view model's companion, the RelayOutcomeLabel widget and the screen reading it, and the GroupSignedEvent comment. The entries screen's Broadcast-all button, its view model and state, its tests, its five strings and its nav-host wiring are line D, which Mantra took out (docs/curated-to-mantra.md). Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@d2a4298d256d419a54b5d0d75a6adda505a927db --- .../database/model/GroupSignedEvent.kt | 7 +- .../BroadcastGroupSignedEventScreen.kt | 16 +- .../composable/widgets/RelayOutcomeLabel.kt | 30 +++ .../BroadcastGroupSignedEventViewModel.kt | 188 +++++++++++------- 4 files changed, 151 insertions(+), 90 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt index e2876be2..c1e70904 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt @@ -36,9 +36,10 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent * columns are `NostrEvent`'s so that what is stored is an event rather than a * summary of one, which is what makes [verifies] answerable from the row alone. * - * The one way one reaches a relay is a member sending it there by hand, from - * `BroadcastGroupSignedEventScreen`, and that path serialises the row straight - * to the pool without ever making a `NostrEvent` of it -- see + * The one way one reaches a relay is a member sending it there by hand -- from + * `BroadcastGroupSignedEventScreen`, or every accepted entry at once from the + * entries screen -- and that path serialises the row straight to the pool + * without ever making a `NostrEvent` of it -- see * `BroadcastGroupSignedEventViewModel` for why a row would be the wrong shape. * * ### The derivation path diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 411a5dcc..80bfd17c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -69,11 +69,6 @@ import mantra.composeapp.generated.resources.copied_the_raw_json import mantra.composeapp.generated.resources.copy_raw_json import mantra.composeapp.generated.resources.eg_wss_relay_example_com import mantra.composeapp.generated.resources.no_relays_to_broadcast_to -import mantra.composeapp.generated.resources.relay_accepted -import mantra.composeapp.generated.resources.relay_could_not_send -import mantra.composeapp.generated.resources.relay_no_answer -import mantra.composeapp.generated.resources.relay_rejected -import mantra.composeapp.generated.resources.relay_sending import mantra.composeapp.generated.resources.relay_url import mantra.composeapp.generated.resources.relays import mantra.composeapp.generated.resources.sends_the_event_exactly_as_the_group_signed_it @@ -95,6 +90,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.label import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.ConformancePreviews @@ -533,15 +529,7 @@ private fun BroadcastRelayRow( outcome?.let { Text( - text = when (it) { - RelayOutcome.Sending -> stringResource(Res.string.relay_sending) - RelayOutcome.Accepted -> stringResource(Res.string.relay_accepted) - is RelayOutcome.Rejected -> - stringResource(Res.string.relay_rejected, it.reason) - is RelayOutcome.Failed -> - stringResource(Res.string.relay_could_not_send, it.reason) - RelayOutcome.NoAnswer -> stringResource(Res.string.relay_no_answer) - }, + text = it.label(), style = MaterialTheme.typography.bodySmall, color = when (it) { RelayOutcome.Accepted -> MaterialTheme.colorScheme.primary diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt new file mode 100644 index 00000000..b756ac6c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt @@ -0,0 +1,30 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.runtime.Composable +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.relay_accepted +import mantra.composeapp.generated.resources.relay_could_not_send +import mantra.composeapp.generated.resources.relay_no_answer +import mantra.composeapp.generated.resources.relay_rejected +import mantra.composeapp.generated.resources.relay_sending +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome + +/** + * One relay's answer to a broadcast, in the words both screens that show one + * use for it: the row under a relay on `BroadcastGroupSignedEventScreen`, and + * the line under an entry that "Broadcast all" sent. + * + * A rejection and a failure carry the relay's own reason, because "blocked: + * not on the allow list" is something a member can act on and a red icon is + * not. One spelling for both screens, so a relay's answer reads the same + * whichever button sent to it. + */ +@Composable +fun RelayOutcome.label(): String = when (this) { + RelayOutcome.Sending -> stringResource(Res.string.relay_sending) + RelayOutcome.Accepted -> stringResource(Res.string.relay_accepted) + is RelayOutcome.Rejected -> stringResource(Res.string.relay_rejected, reason) + is RelayOutcome.Failed -> stringResource(Res.string.relay_could_not_send, reason) + RelayOutcome.NoAnswer -> stringResource(Res.string.relay_no_answer) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt index c4b66f41..10828cc2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt @@ -49,6 +49,11 @@ import kotlin.time.Duration.Companion.milliseconds * event exactly as the group signed it -- the signature is the group's, so any * member holding the event can send it and no share of the key is needed. * + * The send itself, [sendToRelays], is also what the entries screen's "Broadcast + * all" uses, one entry after another and each to the relays [defaultRelaysFor] + * would have seeded here; what is this screen's is the list the member edits + * and the rows that show each relay's answer. + * * ### Why not the broadcast queue * * Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, @@ -93,8 +98,6 @@ class BroadcastGroupSignedEventViewModel( ) private set - private val logger = Logger.withTag(TAG) - val isActionPending: MutableState = mutableStateOf(false) /** @@ -235,17 +238,15 @@ class BroadcastGroupSignedEventViewModel( /** * Sends [signedEvent] to every relay in the list and records what each said. * - * One call to the pool for the whole list, which opens a socket to any relay - * it does not hold and answers once per relay -- an OK, a rejection with the - * relay's reason, or the error that stopped it being asked. Each answer lands - * in [outcomes] as it arrives, so the rows fill in one by one rather than all - * at the end; a relay that has not answered when the whole thing runs out of - * time is marked so, which is different from having been refused. + * The send itself is [sendToRelays], which the entries screen's + * "Broadcast all" shares; what is this screen's is the state around it. + * Each answer lands in [outcomes] as it arrives, so the rows fill in one + * by one rather than all at the end, and [lastBroadcast] sums it up once + * every row has settled. * - * [lastBroadcast] sums it up once every row has settled. [onNoRelays] instead - * of a send to nobody: an empty list is a question rather than a mistake, and - * the pool would otherwise fall back to the *member's* relays, which is not - * what an empty list on this screen means. + * [onNoRelays] instead of a send to nobody: an empty list is a question + * rather than a mistake, and the pool would otherwise fall back to the + * *member's* relays, which is not what an empty list on this screen means. * * Returns the send, for a caller that has to wait for it, and null when * nothing was sent. @@ -269,43 +270,13 @@ class BroadcastGroupSignedEventViewModel( outcomes.clear() targets.forEach { outcomes[it] = RelayOutcome.Sending } - val wireEvent = wireEventOf(signedEvent) - return viewModelScope.launch(Dispatchers.IO) { - try { - withTimeout(BROADCAST_TIMEOUT) { - publishTransport.publishEvent( - nostrEvent = wireEvent, - relays = targets.map { RelayDTO(url = it.url, read = false, write = true) }.toSet(), - ) - // One answer per relay is all the pool ever gives, and the - // flow underneath never completes on its own. - .take(targets.size) - .collect { result -> record(targets, result) } - } - } catch (e: TimeoutCancellationException) { - // Ordinary: a relay that never answers is what the timeout is - // for. The rows still marked as sending are settled below. - logger.w("Broadcast of ${signedEvent.id} ran out of time") - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - // The pool refused the whole call rather than one relay's part of - // it, so every row that was waiting gets the same answer. - logger.e("Broadcast of ${signedEvent.id} failed before any relay answered", e) - targets.forEach { relay -> - if (outcomes[relay] == RelayOutcome.Sending) { - outcomes[relay] = RelayOutcome.Failed(e.message ?: "Could not send") - } - } - } - - targets.forEach { relay -> - if (outcomes[relay] == RelayOutcome.Sending) outcomes[relay] = RelayOutcome.NoAnswer + val settled = sendToRelays(publishTransport, signedEvent, targets) { relay, outcome -> + outcomes[relay] = outcome } lastBroadcast = BroadcastSummary( - accepted = targets.count { outcomes[it] == RelayOutcome.Accepted }, + accepted = settled.count { it.value == RelayOutcome.Accepted }, asked = targets.size, ) @@ -313,37 +284,11 @@ class BroadcastGroupSignedEventViewModel( } } - /** - * Files one relay's answer against the row that asked it. - * - * Matched by host rather than by string, because the pool names a relay by - * the URL it first opened a socket with, and that may carry a trailing slash - * this list's spelling does not. - */ - private fun record(targets: List, result: NostrPublishResult) { - val answered = NormalizedRelayUrl(result.relayUrl).displayUrl() - val relay = targets.firstOrNull { it.displayUrl() == answered } ?: run { - logger.w("Answer from ${result.relayUrl}, which was not asked") - return - } - - val error = result.error - val ok = result.result as? NostrIncomingMessage.OkMessage - - outcomes[relay] = when { - // The pool wraps a relay's ["OK", id, false, reason] in this one - // exception type and nothing else in it, so the message is the - // relay's reason in the relay's words. - error is NostrPublishException -> RelayOutcome.Rejected(error.message ?: "Rejected") - error != null -> RelayOutcome.Failed(error.message ?: "Could not send") - ok?.success == true -> RelayOutcome.Accepted - else -> RelayOutcome.Failed("No OK from the relay") - } - } - companion object { private const val TAG = "BroadcastGroupSignedEventViewModel" + private val logger = Logger.withTag(TAG) + /** * A ceiling on the whole broadcast, past the pool's own per-relay wait, * so a socket that opens and then says nothing cannot hold the button @@ -351,6 +296,103 @@ class BroadcastGroupSignedEventViewModel( */ val BROADCAST_TIMEOUT = (RelayPool.PUBLISH_TIMEOUT * 2).milliseconds + /** + * Sends [signedEvent] to [targets] and says what each relay answered. + * + * One call to the pool for the whole list, which opens a socket to any + * relay it does not hold and answers once per relay -- an OK, a + * rejection with the relay's reason, or the error that stopped it being + * asked. Each answer goes to [onOutcome] as it arrives, so a caller + * showing rows can fill them in one by one rather than all at the end; + * a relay that has not answered when the whole thing runs out of time + * is marked so, which is different from having been refused, and one + * the pool refused to ask at all is marked failed with the pool's reason. + * + * Returns once every relay has settled, with the answers in the order + * [targets] gave them. Shared by the one-event screen and the entries + * screen's "Broadcast all", so that what a relay's answer becomes is + * decided in one place. + */ + suspend fun sendToRelays( + publishTransport: EventPublishTransport, + signedEvent: GroupSignedEvent, + targets: List, + onOutcome: (NormalizedRelayUrl, RelayOutcome) -> Unit = { _, _ -> }, + ): Map { + val outcomes = mutableMapOf() + + fun settle(relay: NormalizedRelayUrl, outcome: RelayOutcome) { + outcomes[relay] = outcome + onOutcome(relay, outcome) + } + + try { + withTimeout(BROADCAST_TIMEOUT) { + publishTransport.publishEvent( + nostrEvent = wireEventOf(signedEvent), + relays = targets.map { RelayDTO(url = it.url, read = false, write = true) }.toSet(), + ) + // One answer per relay is all the pool ever gives, and the + // flow underneath never completes on its own. + .take(targets.size) + .collect { result -> + outcomeOf(targets, result)?.let { (relay, outcome) -> settle(relay, outcome) } + } + } + } catch (e: TimeoutCancellationException) { + // Ordinary: a relay that never answers is what the timeout is + // for. The relays still unanswered are settled below. + logger.w("Broadcast of ${signedEvent.id} ran out of time") + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // The pool refused the whole call rather than one relay's part of + // it, so every relay that was waiting gets the same answer. + logger.e("Broadcast of ${signedEvent.id} failed before any relay answered", e) + targets.forEach { relay -> + if (relay !in outcomes) settle(relay, RelayOutcome.Failed(e.message ?: "Could not send")) + } + } + + targets.forEach { relay -> + if (relay !in outcomes) settle(relay, RelayOutcome.NoAnswer) + } + + return targets.associateWith { outcomes.getValue(it) } + } + + /** + * One relay's answer, against the relay that was asked. + * + * Matched by host rather than by string, because the pool names a relay + * by the URL it first opened a socket with, and that may carry a + * trailing slash the caller's spelling does not. Null for an answer + * from a relay nobody asked, which is logged and dropped. + */ + private fun outcomeOf( + targets: List, + result: NostrPublishResult, + ): Pair? { + val answered = NormalizedRelayUrl(result.relayUrl).displayUrl() + val relay = targets.firstOrNull { it.displayUrl() == answered } ?: run { + logger.w("Answer from ${result.relayUrl}, which was not asked") + return null + } + + val error = result.error + val ok = result.result as? NostrIncomingMessage.OkMessage + + return relay to when { + // The pool wraps a relay's ["OK", id, false, reason] in this one + // exception type and nothing else in it, so the message is the + // relay's reason in the relay's words. + error is NostrPublishException -> RelayOutcome.Rejected(error.message ?: "Rejected") + error != null -> RelayOutcome.Failed(error.message ?: "Could not send") + ok?.success == true -> RelayOutcome.Accepted + else -> RelayOutcome.Failed("No OK from the relay") + } + } + /** * The event as the pool wants it, and nothing more. * From 883bf1c84c0f6a6866d43d1326b63ecb5ab1fdae Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 13:31:47 +0200 Subject: [PATCH 68/71] refactor(ui): one extended FAB for every screen's primary action, with a gap in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty screens end their bottom bar in an `ExtendedFloatingActionButton` -- twenty-one buttons, one screen having two -- and eighteen of those were built from the content-lambda overload with an `Icon` directly followed by a `Text`. That overload puts nothing between the two, so the icon touched the label on every one of them: visible in any render, and unnoticed because every FAB in the app looked the same. Thirteen of the eighteen also carried the same twenty-line block for looking disabled, because M3 gives a FAB no `enabled`. **`ExtendedFab`, once, in widgets/buttons.** A label, an icon slot, `enabled` and `onClick`. It draws `space150` between icon and label, which is the 12dp M3 names `ExtendedFabEndIconPadding`; it borrows the disabled colours every other button in the app uses, marks the node disabled so a screen reader does not announce a button it is happy to press, and drops the press -- so the thirteen call sites lose their `if (!canX) return@…` guards along with the block and the `buttonColors` each of them declared. An icon slot rather than an image, because two screens put a progress indicator where the icon was while the action they started is out, and they still do. **Not M3's `text`/`icon` overload, which would have spaced them for free.** Its source wraps the label in `clearAndSetSemantics {}`: the label leaves the merged semantics tree, a screen reader is told only what the icon says, and a test cannot find the button by what it says -- five screen tests do. The three FABs that were on that overload move to the widget too, and two of them, Home's "New chat" and "Start the key ceremony", had a decorative icon beside the cleared label and so no accessible name at all until now. `ReadOnlyEntrancesJvmTest` had gone to the unmerged tree to find "New chat" for this reason; the habit is harmless and stays. Every icon's description is as it was, since several tests reach their FAB by it. `ExtendedFabJvmTest` pins the decision: found by its label in the merged tree, the icon before the label with a gap between, and disabled both announced and refusing the press. The two docs that recorded the old state -- the conformance account's "left alone" and the npub plan's unmerged-tree note -- each say what superseded them. Verified with :composeApp:m3Audit (all budgets met, 0 dp literals in spacing positions), :composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest (1088 tests, 3 new). Replayed onto Mantra by docs/curated-to-mantra.md: AcceptCuratedSuggestionScreen.kt: deleted, as this commit deletes it upstream; AddGroupPostScreen.kt: deleted, as this commit deletes it upstream; EditGroupCuratedSchemaScreen.kt: deleted, as this commit deletes it upstream; EditGroupNostrProfileScreen.kt: deleted, as this commit deletes it upstream; EditGroupRelaysScreen.kt: deleted, as this commit deletes it upstream; GroupCuratedEntriesScreen.kt: deleted, as this commit deletes it upstream; ProposeGroupEventScreen.kt: deleted, as this commit deletes it upstream. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@ca6c16beb08c7a84ddf224e0b4c5361daad97cb0 --- .../ui/composable/AddArtifactScreen.kt | 44 +------ .../compose/ui/composable/AddChapterScreen.kt | 44 ++----- .../compose/ui/composable/AddDialectScreen.kt | 42 +------ .../AddMemberToChatRoomConfirmationScreen.kt | 18 ++- .../AddTranslationArtifactVersionScreen.kt | 44 ++----- .../BroadcastGroupSignedEventScreen.kt | 39 +----- .../compose/ui/composable/DkgRitualScreen.kt | 47 ++++---- .../compose/ui/composable/HomeScreen.kt | 8 +- .../composable/SelectChatRoomMembersScreen.kt | 27 ++--- .../ui/composable/SelectChatRoomTypeScreen.kt | 37 +++--- .../composable/SelectSubgroupAdminsScreen.kt | 10 +- .../ui/composable/SocialPreconditionScreen.kt | 16 +-- .../ui/composable/TranslateChunkScreen.kt | 44 +------ .../composable/widgets/buttons/ExtendedFab.kt | 80 +++++++++++++ .../widgets/buttons/ExtendedFabJvmTest.kt | 111 ++++++++++++++++++ docs/material-design-conformance.md | 5 +- docs/npub-sign-in.md | 3 + 17 files changed, 306 insertions(+), 313 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFab.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFabJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 60024cbb..7a085e53 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -21,12 +21,9 @@ import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,7 +33,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -46,8 +42,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -87,6 +81,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -148,10 +143,6 @@ fun AddArtifactScreen( // about afterwards, and the button says so. val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -169,28 +160,11 @@ fun AddArtifactScreen( BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canAddArtifact) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (canAddArtifact) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canAddArtifact) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_artifact), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose artifact") }, + enabled = canAddArtifact, onClick = { - if (!canAddArtifact) return@ExtendedFloatingActionButton - addArtifactViewModel.addArtifact( localChatRoom = addArtifactUIState.localChatRoom, nameField = nameFieldState, @@ -220,13 +194,7 @@ fun AddArtifactScreen( ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose artifact" - ) - Text(stringResource(Res.string.propose_artifact)) - } + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index 4fa54ce0..ccc6a166 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -13,10 +13,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -24,13 +21,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -66,6 +60,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -133,10 +128,6 @@ fun AddChapterScreen( paragraphCount > 0 && !tooManyChunks - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -152,27 +143,12 @@ fun AddChapterScreen( BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeChapter) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeChapter) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeChapter) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_chapter), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose chapter") }, + enabled = canProposeChapter, onClick = { - if (artifactVersion == null || !canProposeChapter) { - return@ExtendedFloatingActionButton - } + if (artifactVersion == null) return@ExtendedFab addChapterViewModel.addChapter( localChatRoom = addChapterUIState.localChatRoom, @@ -200,13 +176,7 @@ fun AddChapterScreen( } ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose chapter" - ) - Text(stringResource(Res.string.propose_chapter)) - } + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index ebd8d934..97e8e4d7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -17,11 +17,8 @@ import androidx.compose.material.icons.filled.Title import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -30,14 +27,11 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -71,6 +65,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -119,10 +114,6 @@ fun AddDialectScreen( val countryFieldState = rememberTextFieldState() val languageFieldState = rememberTextFieldState() - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -140,26 +131,11 @@ fun AddDialectScreen( BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (addDialectUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (addDialectUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (addDialectUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_dialect), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose dialect") }, + enabled = addDialectUIState.canSign, onClick = { - if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton - addDialectViewModel.addDialect( localChatRoom = addDialectUIState.localChatRoom, nameField = nameFieldState, @@ -186,13 +162,7 @@ fun AddDialectScreen( } ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose dialect" - ) - Text(stringResource(Res.string.propose_dialect)) - } + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt index 21f5dffc..6f08fd13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -52,6 +51,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -109,7 +109,12 @@ fun AddMemberToChatRoomConfirmationScreen( bottomBar = { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = stringResource( + Res.string.invite_2, + addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey() + ), + icon = { Icon(Icons.Default.Add, contentDescription = "Invite new member") }, onClick = { addMemberToChatRoomConfirmationViewModel.inviteToChatRoom( localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom, @@ -118,14 +123,7 @@ fun AddMemberToChatRoomConfirmationScreen( onInviteSent = onInviteSent ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Invite new member" - ) - - Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey())) - } + ) }, actions = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 2dfc9a4a..dbd2592a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -12,18 +12,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,8 +28,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -67,6 +61,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -135,10 +130,6 @@ fun AddTranslationArtifactVersionScreen( selectedDialect != null && addTranslationUIState.canSign - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { @@ -151,27 +142,12 @@ fun AddTranslationArtifactVersionScreen( BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeTranslation) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeTranslation) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeTranslation) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_translation), + icon = { Icon(Icons.Default.Translate, contentDescription = "Propose translation") }, + enabled = canProposeTranslation, onClick = { - if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) { - return@ExtendedFloatingActionButton - } + if (artifactVersion == null || selectedDialect == null) return@ExtendedFab addTranslationArtifactVersionViewModel.addTranslation( localChatRoom = addTranslationUIState.localChatRoom, @@ -201,13 +177,7 @@ fun AddTranslationArtifactVersionScreen( } ) } - ) { - Icon( - Icons.Default.Translate, - contentDescription = "Propose translation" - ) - Text(stringResource(Res.string.propose_translation)) - } + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 80bfd17c..99fe9a19 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -24,12 +24,9 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Dns import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBarDefaults -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor @@ -41,7 +38,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope @@ -101,6 +97,7 @@ import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlin.time.Instant @@ -193,9 +190,6 @@ fun BroadcastGroupSignedEventScreen( val json = signedEvent.toEvent().toJson() val summary = ProposedEvent.summarize(signedEvent.toEvent()) - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() val canSend = !isPending Scaffold( @@ -213,26 +207,11 @@ fun BroadcastGroupSignedEventScreen( BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canSend) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canSend) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canSend) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.broadcast), + icon = { Icon(Icons.Default.CellTower, contentDescription = "Broadcast to these relays") }, + enabled = canSend, onClick = { - if (!canSend) return@ExtendedFloatingActionButton - broadcastGroupSignedEventViewModel.broadcast( signedEvent = signedEvent, // Stays on the screen: an empty list is @@ -240,13 +219,7 @@ fun BroadcastGroupSignedEventScreen( onNoRelays = { notify(noRelays) } ) } - ) { - Icon( - Icons.Default.CellTower, - contentDescription = "Broadcast to these relays" - ) - Text(stringResource(Res.string.broadcast)) - } + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index 64ce688d..f669285c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -117,6 +116,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews /** @@ -194,7 +194,13 @@ fun DkgRitualScreen( if (pending != null) { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = when (pending) { + DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join) + DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute) + DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm) + }, + icon = { Icon(Icons.Default.Key, contentDescription = Decorative) }, onClick = { onNavigateToRoute( when (pending) { @@ -216,16 +222,7 @@ fun DkgRitualScreen( } ) } - ) { - Icon(Icons.Default.Key, contentDescription = Decorative) - Text( - text = when (pending) { - DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join) - DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute) - DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm) - } - ) - } + ) }, actions = {} ) @@ -238,19 +235,21 @@ fun DkgRitualScreen( if (canStartRitual) { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { dkgRitualViewModel.startRitual() } - ) { - if (isActionPending) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) + ExtendedFab( + label = if (session == null) { + stringResource(Res.string.start_key_ceremony) } else { - Icon(Icons.Default.Key, contentDescription = "Start") - } - - Text( - text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again) - ) - } + stringResource(Res.string.try_again) + }, + icon = { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.Key, contentDescription = "Start") + } + }, + onClick = { dkgRitualViewModel.startRitual() } + ) }, actions = { Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index 7b2ba46a..9cf1ec42 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow @@ -81,6 +80,7 @@ import mantra.composeapp.generated.resources.messages_need_the_secret_key_this_p import mantra.composeapp.generated.resources.sign_in_with_the_nsec import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -456,8 +456,8 @@ private fun ReadOnlyInbox(onSignInWithTheNsec: () -> Unit) { */ @Composable private fun NewChatButton(onClick: () -> Unit) { - ExtendedFloatingActionButton( - onClick = onClick, + ExtendedFab( + label = stringResource(Res.string.new_chat), icon = { Icon( Icons.Default.Add, @@ -465,7 +465,7 @@ private fun NewChatButton(onClick: () -> Unit) { contentDescription = Decorative, ) }, - text = { Text(stringResource(Res.string.new_chat)) }, + onClick = onClick, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index 772a2ce9..c9e888fc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -57,6 +56,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews /** @@ -123,26 +123,21 @@ fun SelectChatRoomMembersScreen( bottomBar = { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = if (selectedCount > 0) { + stringResource(Res.string.next_with, selectedCount) + } else { + stringResource(Res.string.next) + }, + icon = { + Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next") + }, onClick = { selectChatRoomMembersViewModel.selectChatRoomType( onNavigateToRoute = onNavigateToRoute ) } - ) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = "Next" - ) - - Text( - text = if (selectedCount > 0) { - stringResource(Res.string.next_with, selectedCount) - } else { - stringResource(Res.string.next) - } - ) - } + ) }, actions = { Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 5f5399fd..c0705763 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -74,6 +73,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews /** @@ -151,32 +151,25 @@ fun SelectChatRoomTypeScreen( bottomBar = { BottomAppBar( floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { + stringResource(Res.string.open_chat) + } else { + stringResource(Res.string.create_chat) + }, + icon = { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.Check, contentDescription = "Create chat") + } + }, onClick = { selectChatRoomTypeViewModel.createChatRoom( onNavigateToRoute = onNavigateToRoute ) } - ) { - if (isActionPending) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - Icons.Default.Check, - contentDescription = "Create chat" - ) - } - - Text( - text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { - stringResource(Res.string.open_chat) - } else { - stringResource(Res.string.create_chat) - } - ) - } + ) }, actions = { Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt index 44110cbf..a104bc1e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -61,6 +60,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme @@ -123,7 +123,9 @@ fun SelectSubgroupAdminsScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( + ExtendedFab( + label = stringResource(Res.string.start_the_key_ceremony), + icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) }, onClick = { scope.launch { // The refusal is recomputed rather than cached on @@ -137,9 +139,7 @@ fun SelectSubgroupAdminsScreen( viewModel.confirm(onNavigateToRoute) } } - }, - icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) }, - text = { Text(stringResource(Res.string.start_the_key_ceremony)) } + } ) } ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt index 936fb60d..b221d50a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -33,6 +32,7 @@ import mantra.composeapp.generated.resources.view_invites import mantra.composeapp.generated.resources.who_will_you_be_passing_the_aux_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.theme.ConformancePreviews @@ -51,17 +51,9 @@ fun SocialPreconditionScreen( }, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = Modifier, - icon = { - Icon( - Icons.Default.SkipNext, - contentDescription = "Skip for now" - ) - }, - text = { - Text(stringResource(Res.string.skip_for_now)) - }, + ExtendedFab( + label = stringResource(Res.string.skip_for_now), + icon = { Icon(Icons.Default.SkipNext, contentDescription = "Skip for now") }, onClick = { onNavigateToSkipForNow.invoke() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt index bd9bd0af..6af370c2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -14,11 +14,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -26,13 +23,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -64,6 +58,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @@ -111,10 +106,6 @@ fun TranslateChunkScreen( is TranslateChunkUIState.Loaded -> { val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software // keyboard covers whatever is being typed into. Scaffold( @@ -130,28 +121,11 @@ fun TranslateChunkScreen( BottomAppBar( actions = {}, floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (translateChunkUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (translateChunkUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (translateChunkUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, + ExtendedFab( + label = stringResource(Res.string.propose_translation), + icon = { Icon(Icons.Default.Add, contentDescription = "Propose translation") }, + enabled = translateChunkUIState.canSign, onClick = { - if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton - translateChunkViewModel.proposeTranslation( localChatRoom = translateChunkUIState.localChatRoom, originalChunk = translateChunkUIState.originalChunk, @@ -178,13 +152,7 @@ fun TranslateChunkScreen( } ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose translation" - ) - Text(stringResource(Res.string.propose_translation)) - } + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFab.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFab.kt new file mode 100644 index 00000000..b2ffc81b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFab.kt @@ -0,0 +1,80 @@ +package press.mantra.compose.ui.composable.widgets.buttons + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import press.mantra.compose.ui.theme.spacing + +/** + * A screen's one primary action, as an extended FAB with a label beside its icon. + * + * Every bottom bar in the app ends in one of these -- propose, broadcast, next, + * create -- and this is the one place their shape is decided. Two decisions: + * + * - **The gap between icon and label is drawn here.** M3's `ExtendedFloatingActionButton` + * comes in two overloads. The `text`/`icon` one spaces them 12dp apart, but wraps the + * label in `clearAndSetSemantics`, so a screen reader is told only what the icon says + * and a test cannot find the button by its label; the content-lambda one keeps the + * label in the tree and puts nothing between the two. This uses the second and adds + * the first's gap, `space150`, which is the 12dp M3 names `ExtendedFabEndIconPadding`. + * The label is what the button says, so it has to be what the button *announces*. + * - **A FAB has no `enabled`, so unavailable is a look and a refusal, not a state.** + * M3 gives the FAB no such parameter. Here [enabled] false borrows the disabled + * colours every other button in the app uses rather than inventing a shade, marks the + * node disabled so a screen reader does not announce a button it is happy to press, + * and drops the press -- looking unavailable is not being unavailable, and each of + * those three has to be done for the look to be honest. + * + * @param label what the button says. Text, so from the catalogue; drawn after the icon. + * @param icon what sits before the label. Usually an `Icon` -- whose description is the + * caller's decision, `Decorative` when the label says it all and a real one when a test + * reaches for the button by it -- and sometimes a progress indicator in its place while + * the action it started is out. + * @param enabled whether a press does anything. False also changes the look, as above. + */ +@Composable +fun ExtendedFab( + label: String, + icon: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val buttonColors = ButtonDefaults.buttonColors() + + ExtendedFloatingActionButton( + modifier = if (enabled) { + modifier + } else { + modifier.semantics { disabled() } + }, + containerColor = if (enabled) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (enabled) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (enabled) onClick() + } + ) { + icon() + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space150)) + + Text(label) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFabJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFabJvmTest.kt new file mode 100644 index 00000000..38fe09f1 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/buttons/ExtendedFabJvmTest.kt @@ -0,0 +1,111 @@ +package press.mantra.compose.ui.composable.widgets.buttons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.MantraTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The two things the shared FAB decides for every screen's primary action. + * + * **The label is what the button announces.** M3's `text`/`icon` overload clears the + * label from the merged semantics tree, so a screen reader hears only the icon and a + * test cannot find the button by what it says. Every screen test in the app that + * reaches a FAB by `onNodeWithText` depends on this one staying the other way, and + * a FAB with a decorative icon has no name at all without it. + * + * **Unavailable is a refusal, not only a look.** M3 gives a FAB no `enabled`, so the + * widget has to both mark the node disabled and drop the press; a button that only + * looked disabled would be announced, and pressed, as one that works. + */ +@OptIn(ExperimentalTestApi::class) +class ExtendedFabJvmTest { + + @Test + fun `the button is found by its label in the merged tree, with the icon beside it`() = + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Propose post", + icon = { Icon(Icons.Default.Add, contentDescription = "Propose post icon") }, + onClick = {}, + ) + } + } + + // No `useUnmergedTree`: this is the tree a screen reader is given. + onNodeWithText("Propose post").assertIsDisplayed().assertIsEnabled() + + // The icon first, then a gap, then the label -- not one touching the other. + val icon = onNodeWithContentDescription("Propose post icon", useUnmergedTree = true) + .getBoundsInRoot() + val label = onNodeWithText("Propose post", useUnmergedTree = true).getBoundsInRoot() + assertTrue(icon.right < label.left, "the label is not after the icon") + } + + @Test + fun `a decorative icon leaves the label as the button's only name, which is enough`() = + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Broadcast all", + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + onClick = {}, + ) + } + } + + onNodeWithText("Broadcast all").assertIsDisplayed() + } + + @Test + fun `disabled is announced as disabled and drops the press, and enabled takes it`() { + var presses = 0 + + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Propose post", + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + enabled = false, + onClick = { presses++ }, + ) + } + } + + onNodeWithText("Propose post").assertIsNotEnabled().performClick() + } + assertEquals(0, presses) + + runDesktopComposeUiTest(400, 200) { + setContent { + MantraTheme { + ExtendedFab( + label = "Propose post", + icon = { Icon(Icons.Default.Add, contentDescription = Decorative) }, + onClick = { presses++ }, + ) + } + } + + onNodeWithText("Propose post").assertIsEnabled().performClick() + } + assertEquals(1, presses) + } +} diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index 50a2e1c0..13301d2d 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -696,7 +696,10 @@ let the component apply the 38% state layer. No `FloatingActionButton` overload material3 1.10 takes `enabled` — the spec's position is that an unavailable FAB should not appear at all — so hand-computing the colours is the only way to show one, and the existing code already pairs it with `Modifier.semantics { disabled() }` so a screen reader does not -announce a button it is happy to press. Left alone. +announce a button it is happy to press. Left alone at the time; since done once, in +`ExtendedFab`, which every screen's bottom-bar action now goes through and which also +puts M3's own 12dp between the icon and the label — the content overload the screens were +using put nothing there. **Left for a person.** Eight more screens have two or more filled buttons competing: LandingScreen's "Sign in" beside "Create profile", SocialPreconditionScreen's "Invite a diff --git a/docs/npub-sign-in.md b/docs/npub-sign-in.md index 13672418..68193b62 100644 --- a/docs/npub-sign-in.md +++ b/docs/npub-sign-in.md @@ -36,6 +36,9 @@ floating action button's label by text has to search the unmerged tree, because `ExtendedFloatingActionButton` merges its label into the button's semantics — and on the merged tree an `assertDoesNotExist` for a hidden control is vacuously true. `ReadOnlyEntrancesJvmTest` uses the unmerged tree for every lookup for that reason. +(More precisely, the `text`/`icon` overload *clears* the label's semantics, so the +merged tree never had it; the shared `ExtendedFab` that replaced it keeps the label +in the merged tree, and the test's habit is now only a habit.) The library commit is on `claude/nostr-credentials` in the submodule, at `84cc44c`, one commit ahead of the library's `master` and bumped into the app by the Phase 2 From e66c174b4f82f60d79281c3c97f6676bdd8af64f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 13:43:20 +0200 Subject: [PATCH 69/71] feat(ui): the preview's action is the app's extended FAB curated gained ExtendedFab while the preview was being built: every screen's bottom-bar action, with an enabled that borrows the disabled colours, marks the node disabled and drops the press. The preview had chosen a Button for exactly the reason that widget removes -- a FAB had no disabled state -- so after the merge it was the one screen whose primary action was shaped somewhere else. Now it is that FAB in a bottom bar, the indicator in its icon slot while the key package is still owed, and the reason line stays in the body above it. Hidden, bar and all, for a read-only identity. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@80735df86ada10c65e044b1e8bbbcf8c8655b4db --- .../ui/composable/ProfilePreviewScreen.kt | 172 ++++++++++-------- docs/npub-profile-preview.md | 4 +- 2 files changed, 97 insertions(+), 79 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt index 27d0cc46..ea5d41da 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProfilePreviewScreen.kt @@ -2,18 +2,15 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mail import androidx.compose.material.icons.filled.PersonSearch -import androidx.compose.material3.Button +import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator @@ -28,7 +25,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -58,6 +54,7 @@ import press.mantra.compose.ui.composable.widgets.LocalCanSign import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.NavigateBackButton import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.ConformancePreviews import press.mantra.compose.ui.theme.MantraTheme @@ -76,9 +73,13 @@ import press.mantra.compose.ui.view.state.ProfilePreviewUIState.Readiness * where it has always been made, and this screen is popped under it, as the profile * detail is under a chat started from it. * - * The button reads the state's readiness: a chat that exists opens, one that can be - * started starts, and one that cannot yet says why under a disabled button rather - * than twenty seconds into the next screen. See docs/npub-profile-preview.md. + * The action is the app's extended FAB, in the bottom bar where every screen's + * primary action goes, and it reads the state's readiness: a chat that exists opens, + * one that can be started starts, and one that cannot yet is disabled with the + * reason in the body above it rather than twenty seconds into the next screen. Hidden + * altogether, bar and all, for a read-only identity: a message is a gift wrap sealed + * with the sender's key, and there is nothing such an identity can do here but look. + * See docs/npub-profile-preview.md. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -105,6 +106,8 @@ fun ProfilePreviewScreen( // pasted, and the block is not a confirmation without them. val npub = remember(profilePublicKey) { profilePublicKey.hexToNpubHrp() } + val canSign = LocalCanSign.current + Scaffold( snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { @@ -113,8 +116,42 @@ fun ProfilePreviewScreen( navigationIcon = { NavigateBackButton(onNavigateBack) }, ) }, + bottomBar = { + // Read straight off the view model rather than inside the transition below: + // the bar is a slot of the scaffold, not of the content, and it exists only + // once there is a person to act on. + val state = profilePreviewViewModel.profilePreviewUIState + if (canSign && state is ProfilePreviewUIState.Loaded) { + BottomAppBar( + floatingActionButton = { + ChatAction( + readiness = state.readiness, + onOpenChat = { chatRoomId, relayHint -> + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + ) + ) + }, + onStartChat = { + onStartChat( + ChatRoomMessagingRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = profilePublicKey, + relayHint = null, + ) + ) + }, + ) + }, + actions = {}, + ) + } + }, ) { innerPadding -> - // The bar is constant, so the transition wraps the content alone. None of the + // The bars are constant, so the transition wraps the content alone. None of the // branches needs `ColumnScope`, which is the one thing that costs. ScreenStateTransition(profilePreviewViewModel.profilePreviewUIState) { uiState -> when (val state = uiState) { @@ -141,30 +178,12 @@ fun ProfilePreviewScreen( ) { ProfileBlock(profile = state.profile, publicKey = profilePublicKey, npub = npub) - // A message is a gift wrap, sealed with the sender's key. Not for a - // read-only identity, which is shown the person and nothing to do. - if (LocalCanSign.current) { - ChatAction( + // Why the button in the bar below is or is not pressable. Nothing + // for a read-only identity: there is no button to explain. + if (canSign) { + ReadinessLine( name = state.profile.humanReadableNameOrPubkey(), readiness = state.readiness, - onOpenChat = { chatRoomId, relayHint -> - onStartChat( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = relayHint, - ) - ) - }, - onStartChat = { - onStartChat( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = profilePublicKey, - relayHint = null, - ) - ) - }, onRetry = { profilePreviewViewModel.retry() }, ) } @@ -266,62 +285,59 @@ private fun Npub(npub: String) { /** * The one thing to do about the person, and whether it can be done yet. * - * A `Button`, not the bottom-bar `ExtendedFloatingActionButton` the invite - * confirmation uses, because a FAB has no disabled state in M3 or in the API, and a - * disabled button with the reason under it is what the action needs while the relays - * are still answering: 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. + * The app's extended FAB, so that this screen's primary action is shaped where every + * other screen's is. Its `enabled` is what the readiness decides, and while the key + * package is still owed the icon's slot holds the indicator, as the two screens with an + * action out on the relays do. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ChatAction( - name: String, readiness: Readiness, onOpenChat: (chatRoomId: String, relayHint: String?) -> Unit, onStartChat: () -> Unit, +) { + ExtendedFab( + label = stringResource( + if (readiness is Readiness.ExistingChat) Res.string.open_chat else Res.string.start_new_chat + ), + icon = { + if (readiness == Readiness.Checking) { + LoadingIndicator() + } else { + Icon( + Icons.Default.Mail, + // The label is beside it. + contentDescription = Decorative, + ) + } + }, + enabled = readiness is Readiness.ExistingChat || readiness == Readiness.CanStartChat, + onClick = { + when (readiness) { + is Readiness.ExistingChat -> onOpenChat(readiness.chatRoomId, readiness.relayHint) + else -> onStartChat() + } + }, + ) +} + +/** + * The reason the button below is or is not pressable: a supporting line, not a second + * message, and nothing at all when the answer is simply yes. + */ +@Composable +private fun ReadinessLine( + name: String, + readiness: Readiness, onRetry: () -> Unit, ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), - ) { - Button( - modifier = Modifier.fillMaxWidth(), - enabled = readiness is Readiness.ExistingChat || readiness == Readiness.CanStartChat, - onClick = { - when (readiness) { - is Readiness.ExistingChat -> onOpenChat(readiness.chatRoomId, readiness.relayHint) - else -> onStartChat() - } - }, - ) { - Icon( - Icons.Default.Mail, - // The label is beside it. - contentDescription = Decorative, - ) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) - Text( - stringResource( - if (readiness is Readiness.ExistingChat) Res.string.open_chat else Res.string.start_new_chat - ) - ) - } - - // A supporting line, not a second message. - when (readiness) { - is Readiness.ExistingChat -> SupportingLine(stringResource(Res.string.you_already_have_a_chat_with_s, name)) - Readiness.CanStartChat -> Unit - Readiness.Checking -> { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), - ) { - LoadingIndicator() - SupportingLine(stringResource(Res.string.checking_whether_s_can_receive_messages_here, name)) - } - } - Readiness.NotYetOnMantra -> { + when (readiness) { + is Readiness.ExistingChat -> SupportingLine(stringResource(Res.string.you_already_have_a_chat_with_s, name)) + Readiness.CanStartChat -> Unit + Readiness.Checking -> SupportingLine(stringResource(Res.string.checking_whether_s_can_receive_messages_here, name)) + Readiness.NotYetOnMantra -> { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { SupportingLine(stringResource(Res.string.s_hasn_t_set_up_messaging_on_mantra_yet, name)) TextButton(onClick = onRetry) { Text(stringResource(Res.string.try_again)) diff --git a/docs/npub-profile-preview.md b/docs/npub-profile-preview.md index 848c417b..77ab69b5 100644 --- a/docs/npub-profile-preview.md +++ b/docs/npub-profile-preview.md @@ -26,6 +26,7 @@ reasoning; where the implementation chose differently the table below says so: | `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 | One thing found on the way that is in no phase: the dialog's `Dialog` renders inside the desktop test root, so `onNodeWithText` finds its field and its button without a @@ -404,7 +405,8 @@ Button(onClick = { 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. +FAB has no disabled state in M3 or in the API. (Superseded before the merge — see the +table at the top.) 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 From 4899dcbca7fe9f0974243b4bcab3c050b256e85b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 14:44:57 +0200 Subject: [PATCH 70/71] feat(ui): a member on the room's screen opens the profile preview The member rows on the chat room detail were the last "Profile detail" stub in the app: each pushed ImplementationPendingRoute. They now push ProfilePreviewRoute by the member's public key, which the nav host already serves, so a member arrives with the cached profile at once and the relays refreshing it, and the bar's action opens the chat that exists with them or starts one. The preview rather than the profile detail, because the detail is keyed by a kind 0's event id and a member whose profile has not arrived yet -- the "LOADING..." row MemberProfileSync exists for -- has none. The public key is the one thing every row has. A test pins both members opening the same screen, the named one and the one still named by its key. Replayed onto Mantra by docs/curated-to-mantra.md: ChatRoomDetailScreen.kt: the ImplementationPendingRoute import this commit removes is kept -- upstream's file had no other use for it once 808a3459 dropped the library, dialects and projects sections, and Mantra's projects section, which it kept, still routes "Add new project" through it. A semantic conflict rather than a textual one: the pick applied cleanly and the compiler found it; GroupSignedWorkFixtures.kt: the test this commit adds composes the screen against the fork's fixture of that name, which went with lines B, D and H -- it is recreated here reduced to the room alone, under the same names, so the test lands byte for byte. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@272b6316337dd2137b767de00ea9c958b781f371 --- .../ui/composable/ChatRoomDetailScreen.kt | 13 +- .../ui/composable/ChatRoomMemberRowJvmTest.kt | 127 ++++++++++++++++++ .../ui/composable/GroupSignedWorkFixtures.kt | 31 +++++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatRoomMemberRowJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 37a3ae27..0689fb34 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -67,6 +67,7 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.MantraRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -693,11 +694,19 @@ fun ChatRoomDetailScreen( key = { localParticipant -> localParticipant.participant.participantPublicKey} ) { localParticipant -> + // The preview, not the profile detail: the detail is + // keyed by a kind 0's event id, and a member whose + // profile has not arrived yet -- the "LOADING..." row + // the sync exists for -- has none. The preview is + // keyed by the public key, which is the one thing + // every row here has, and it shows the cached + // profile at once while the relays refresh it. Card( onClick = { onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Profile detail" + ProfilePreviewRoute( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = localParticipant.participant.participantPublicKey ) ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatRoomMemberRowJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatRoomMemberRowJvmTest.kt new file mode 100644 index 00000000..26fdfdad --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatRoomMemberRowJvmTest.kt @@ -0,0 +1,127 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.activeUserPublicKey +import press.mantra.compose.ui.composable.GroupSignedWorkFixtures.chatRoomId +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ChatRoomDetailUIState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The member rows on the room's screen, and what one opens. + * + * A row is a promise about the screen behind it, and this one was a stub for long + * enough that the promise is worth pinning: a member opens the profile preview, + * keyed by the member's public key, with the active identity beside it so the + * preview can say whether a chat with them already exists. + * + * Two members, because the screen names them differently and the row has to open + * the same screen either way. One has a profile and is named by it. The other has + * none yet -- the "LOADING..." row `MemberProfileSync` exists for -- and is named + * by the key; that member is the reason the row opens the preview rather than the + * profile detail, which is keyed by a kind 0's event id and has nothing to open for + * a member whose kind 0 has not arrived. + */ +@OptIn(ExperimentalTestApi::class) +class ChatRoomMemberRowJvmTest { + + private val named = "b".repeat(64) + private val stillLoading = "c".repeat(64) + + @Test + fun `a member with a profile opens the preview by their key`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Nomvula").performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, named), opened) + } + + @Test + fun `a member whose profile has not arrived opens the preview by their key too`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText(stillLoading).performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, stillLoading), opened) + } + + private fun member(publicKey: String, profile: Profile?) = LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = profile, + ) + + private fun state() = ChatRoomDetailUIState.Loaded( + localChatRoom = GroupSignedWorkFixtures.localChatRoom().copy( + localParticipants = listOf( + member( + named, + Profile( + publicKey = named, + displayName = "Nomvula", + nostrEventId = "e".repeat(64), + ), + ), + member(stillLoading, profile = null), + ), + ), + ) + + /** + * The screen at a phone's width and a window tall enough to compose the whole + * of it, as `GroupSignedWorkRowsJvmTest` renders it: the members are near the + * bottom, and a `LazyColumn` does not lay out what it would not show. + */ + private fun render( + onNavigateToRoute: (Route) -> Unit, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 4000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + ChatRoomDetailScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = null, + initialChatRoomDetailUIState = state(), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = onNavigateToRoute, + onPopBackToRoute = {}, + onNavigateBack = {}, + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt new file mode 100644 index 00000000..c4b22221 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupSignedWorkFixtures.kt @@ -0,0 +1,31 @@ +package press.mantra.compose.ui.composable + +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +/** + * A group room to compose the group's screen against. + * + * The fork's fixture of this name also carries the signed work the group's screen + * showed there -- a profile, relay lists, posts, curated schemas and entries -- which + * Mantra took out with lines B, D and H (docs/curated-to-mantra.md). What is left is + * the room itself, under the same names, so that a test written upstream against the + * room alone compiles here unchanged; `ChatRoomMemberRowJvmTest` is the first. + */ +internal object GroupSignedWorkFixtures { + + val chatRoomId = "d4c3b2a1".repeat(8) + + val activeUserPublicKey = "a".repeat(64) + + fun localChatRoom(mlsGroupState: String? = "state") = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = activeUserPublicKey, + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = mlsGroupState + ) + ) +} From 76055bc0a86615be61c4ebecaf696c33f2a5fde7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 16:42:58 +0200 Subject: [PATCH 71/71] docs: record the third pull, the profile preview and the extended FAB Eleven commits reached the fork after the second pull had landed, and this note is their inventory, verdicts and record: the profile-preview line pulled whole, the extended-FAB sweep pulled with its seven removed screens resolved as still deleted, and d2a4298d -- one commit half in the curated-entries line Mantra took out and half in the broadcast line it kept -- pulled in part, by a PARTIAL rule that applies the kept paths and writes the rest into the note. Three things differed from the pulls before it and are set out: a brand token the normaliser could not see, NotYetOnCurare, fixed by a rule and a second rewrite that left the earlier forty-nine commits hash for hash the same; a merge whose join files carried only the dropped half, so that for the first time a join was not taken from the merge; and two conflicts the compiler found where git saw none, an import Mantra's projects section still needs and a jvmTest fixture recreated reduced to the room so that an upstream test lands byte for byte. The record is two phases: jvmTest 871 -> 909 -> 914, testDebugUnitTest 420 -> 424, every audit budget met at both cuts, the schema unchanged at version 20, and an exactness check whose every movement is named. The README gains the row and a sentence; the second plan points forward. Co-Authored-By: Claude Opus 5 --- docs/README.md | 4 +- docs/curated-to-mantra-preview.md | 111 +++++++++++++++++++++++++++++ docs/curated-to-mantra-profiles.md | 3 + 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 docs/curated-to-mantra-preview.md diff --git a/docs/README.md b/docs/README.md index eb82fa8c..933e8da5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ silent, or a decision that looked arbitrary and was not. | [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 | +| [curated-to-mantra-preview.md](./curated-to-mantra-preview.md) | the third pull: the profile preview and the extended FAB, eleven commits — a brand token hidden in CamelCase, a commit half in a removed line taken as a partial pick, and the two conflicts the compiler found where git saw none | Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" @@ -50,7 +51,8 @@ npub sign-in notes arrived with it and record what they built. The profiles pull is the same exercise a second time, planned with its dry run already done and built the same day; read it after the first, because it assumes the method and the three decisions and only says what changed — chiefly that the tree is no longer a superset, -and what the check for an exact pull becomes when it is not. +and what the check for an exact pull becomes when it is not. The preview pull note is +the third and shortest; read it for the two kinds of resolution the first two never needed. The nsec sign-in note is a phased plan that has been built; it inherits the key-storage decision from the jvm-target note and drives the navigation state machine `NavigationViewModel.processLocalAccount` implements, so read it with the diff --git a/docs/curated-to-mantra-preview.md b/docs/curated-to-mantra-preview.md new file mode 100644 index 00000000..0899c73d --- /dev/null +++ b/docs/curated-to-mantra-preview.md @@ -0,0 +1,111 @@ +# The third pull: the profile preview, the extended FAB, and half of a commit + +Eleven commits reached `curated/curated` on 2026-09-13, after the +[second pull](./curated-to-mantra-profiles.md) had landed — ten ordinary and one merge, +`29027f2b..272b6316` — and they sat unpushed in a local clone long enough to be asked +about. This is what they are, which of them Mantra took, and the three places this pull +differed from the two before it. It assumes the [first plan](./curated-to-mantra.md)'s +method and the second's exactness check, and says only what changed. + +**Built 2026-09-13**, the day they were pushed: eight commits landed whole, one in part, +one merge as nothing, in two phases, at `jvmTest` 871 → 914 and `testDebugUnitTest` +420 → 424 with every audit budget met. + +## The inventory + +| line | commits | what | verdict | +|---|---|---|---| +| **A profile preview before a chat** — [npub-profile-preview.md](./npub-profile-preview.md) | `db268a53` plan · `37c6f7ea` `8973c005` `923b59ec` `d6490985` · `b3bcc060` record · `80735df8` · `272b6316` | `ProfilePreviewScreen` and its view model, with `DirectMessagePeerSync` behind the button's readiness; the new-chat dialog opens the person instead of starting the chat; the button knows whether a room with them already exists; the preview's action is the app's extended FAB; a member row on the group's screen opens the preview | **pulled**, all eight | +| **One extended FAB for every primary action** | `ca6c16be` | `ExtendedFab`, and twenty screens moved onto it — thirteen of them Mantra's, including `AddArtifact`, `AddChapter`, `AddDialect`, `AddTranslationArtifactVersion`, `TranslateChunk`, `DkgRitual` | **pulled**; the seven screens Mantra does not have resolve as *still deleted* | +| **Broadcast every accepted entry** | `d2a4298d` | a *Broadcast all* on the curated-entries screen, and — to make it — `sendToRelays` lifted into `BroadcastGroupSignedEventViewModel`'s companion, a `RelayOutcomeLabel` widget, and the broadcast screen reading it | **pulled in part**: the broadcast half; the entries half is line D | +| the merge | `f4b0347e` | the preview branch joining `curated` | lands as nothing; its two join files carried only line D's side, see below | + +## Three things that differed + +### 1. A brand token the normaliser did not know + +The preview's view model names a readiness state `NotYetOnCurare`. `\bCurare\b` cannot +see a brand with no word boundary in front of it, and the first rewrite left seventeen +of them across the code, the tests and the note. The fix is a rule, not an edit — +`(?<=[a-z0-9])Curare(?=[A-Z]|\b)` → `Mantra`, for the Curare generation only, since a +CamelCase-embedded *Curated* is product vocabulary at the tip — and a second rewrite: +zero tokens left, and the earlier forty-nine rewritten commits came out hash for hash +the same, so nothing already landed was disturbed. `0728f3f9`. + +### 2. Half of a commit, taken as a partial pick + +`d2a4298d` is one commit in two lines. Its button, view model, state, tests, five strings +and nav-host wiring belong to the curated-entries screen, which Mantra took out; but the +send it lifted out of the broadcast screen into `sendToRelays`, the widget that turns a +relay's answer into words, and the screen reading it are the broadcast line Mantra kept, +and they touch only files both trees hold. Dropping the commit whole would have left four +shared files differing from the fork's and put the FAB commit, which edits the same +screen, on a base the fork's never had. The driver gained a `PARTIAL` rule: the diff +restricted to the kept paths, applied and committed under the original message and +trailer, with what was left out written into the note. `b3379f81`, and the FAB commit +then applied to the screen cleanly. + +What this does to the merge: `f4b0347e`'s two join files, `strings.xml` and the nav +host, carried the preview side's additions — which the preview commits had already +brought — plus `d2a4298d`'s entries strings and wiring. So the merge was not a source of +anything Mantra wanted, and for the first time a join's files were *not* taken from the +merge. The residual records the difference exactly: five strings and their comment, and +two nav-host hunks, one of which is a comment that says "as the entries screen is for +its Broadcast all" where Mantra's still says "the one screen", which on Mantra is true. + +### 3. Two conflicts the compiler found, not git + +Both in `272b6316`, which applied without a textual conflict. + +- Upstream's `ChatRoomDetailScreen` had no use left for `ImplementationPendingRoute` + once `808a3459` dropped the library, dialects and projects sections, so the commit + removes the import. Mantra kept the sections, and its projects section still routes + *Add new project* through that import. Kept, with the reason in the commit. +- The test it adds, `ChatRoomMemberRowJvmTest`, composes the screen against + `GroupSignedWorkFixtures`, a jvmTest fixture that went with lines B, D and H. The + test uses three of its members — the room id, the active key, `localChatRoom()` — and + none of the signed work. The fixture is recreated on Mantra reduced to the room alone, + under the same names, so the test lands byte for byte and the next upstream test + written against the room compiles here too. It is the one file this pull turned from + *upstream only* into *both have, differently*. + +The seven modify/delete conflicts at `ca6c16be` were the expected kind and resolved by +rule: `AcceptCuratedSuggestion`, `AddGroupPost`, `EditGroupCuratedSchema`, +`EditGroupNostrProfile`, `EditGroupRelays`, `GroupCuratedEntries`, +`ProposeGroupEvent` — all still deleted. + +## The record + +| | Phase A — the preview line to its record | Phase B — the broadcast half, the FAB, the two on top | +|---|---|---| +| commits | `69aa43a1` `f84ae133` `39c056a2` `5ba888d9` `2adc5036` `a8ba589e` | `b3379f81` (partial) `883bf1c8` (seven deletes) `e66c174b` `4899dcbc` (two hand fixes) | +| picks | 6 clean | 1 partial, 1 by rule, 2 clean | +| exactness | 139 residual files before and after; nothing moved | 140; moved: `strings.xml` (the five strings), `MantraNavHost.kt` (the two hunks), `ChatRoomDetailScreen.kt` (one import), `GroupSignedWorkFixtures.kt` (recreated); twelve upstream-only files changed upstream, all of them line D's or the removed screens' | +| compile | both targets clean | both targets clean | +| `jvmTest` | 871 → 909 | 909 → 914 | +| `testDebugUnitTest` | 420 → 424 (`DirectMessagePeerSyncTest`) | 424 | +| audit | all budgets met; composable literals 30 | all budgets met; 30 | +| `strings.xml` | 455 → 460 | 460 | +| schema | unchanged, v20 | unchanged, v20 | + +The rewrite: 60 commits in 128 s, twice. The library pin: `84cc44c` throughout. + +## What the user gets + +A member's row on a group's screen, and the person a new chat is started with, open a +profile preview: the cached profile at once while the relays refresh it, and one +button that says whether a chat with them exists, can be started, or cannot yet because +they have not set up messaging on Mantra. And one extended FAB, the same shape and the +same disabled state, on every screen whose primary action used to spell it its own way +— including the translation screens Curare kept and fixed. + +## What is left + +- **The other half of `d2a4298d`** is line D and stays out; the residual names its lines. +- **The reverse direction.** `ExtendedFab` and the preview are now identical on both + trees; the only Mantra-side edits in this pull are the kept import and the reduced + fixture, neither of which the fork wants. Nothing new to offer upstream from here. +- **The fork decision** stands as the first plan left it. Three pulls in one day with + the tooling in place cost minutes each of machine time; each also found one thing — + a pin, a CamelCase brand, a commit in two lines — that a rebrand-free fork would not + have had to find. diff --git a/docs/curated-to-mantra-profiles.md b/docs/curated-to-mantra-profiles.md index 6ff8936d..02bce266 100644 --- a/docs/curated-to-mantra-profiles.md +++ b/docs/curated-to-mantra-profiles.md @@ -16,6 +16,9 @@ lessons; this document assumes both and adds what changed. The one rule everythi still rests on is the derivation note's: two strings in this tree are hash inputs and are never renamed, and the normaliser cannot touch them by construction. +A third pull followed the same day — eleven commits for a profile preview and an +extended FAB — and is recorded in [curated-to-mantra-preview.md](./curated-to-mantra-preview.md). + **Built 2026-09-13**, the same day, on the branch that carries this plan: the ten commits landed by cherry-pick in four phases, each with a `Pulled-From` trailer, at the dry run's numbers exactly; the phases below carry a *Built* paragraph each with