From 776455ecb0ae4e5de5e6285c82d52f282113857d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 14:45:15 +0200 Subject: [PATCH] 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', }