From 590230f2151601dcfea551590bb0f03009f97b39 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Thu, 10 Sep 2026 11:30:53 +0200 Subject: [PATCH] feat(groups): the group's own nostr identity, and the two ways a member could forge it A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`, where those are one value -- so every group in this app has had a nostr identity from the moment it had a key, and has never had a way to say anything about it. This adds the two statements that identity is made of: a kind:0 saying who the group is, and the four relay lists saying where it can be found. Both are signed by the group's quorum like everything else it says. Two sections on the group's detail screen, between the signing key and the library, and two editors behind them. Neither editor saves: what leaves them is a `FrostSigningSession`, and the profile changes on every member's device at once when enough members sign, or not at all. **A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.** `Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a `NostrEvent`: no member sent it, it never travelled the wire as itself, and the outbound pipeline re-authors rumors as their sender and would strip the group's signature off -- which is exactly the position `GroupSignedEvent` exists for. So `GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are readings rather than rows. Nothing new is stored: `FrostSigningManager.complete` already files every signed event before it applies one, so both readers had their data before this change and neither adds a table, a migration or a DAO method beyond the two existing kind-scoped queries. **Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust model.** The author has to be the room, the id has to be the hash of the fields beside it, and the signature has to verify -- checkable from the row alone, with no ceremony or key state to consult. A kind:0 that merely arrived in the room is not the group's profile; a relay list filed against the room by another group is not the group's relay list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's signature, a forged author and three kinds of rubbish in the signature field are all tested against the code that actually verifies rather than against a mock of it. **The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft did not.** Every kind the group signs needs an arm there or it falls through to `unsupported` and puts raw JSON in the transcript -- so a profile update would have appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot tell them apart: a completed signing session, where the author is the room by construction, and an arriving inner event, where it is whatever the sender wrote. A rumor carries an empty signature and any pubkey its sender likes, so the relay arm's original author-only check would have let one member write "the group signed its general relay list" into the room's transcript with no group involved. It now builds a `GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list passing through the room fails the author half and gets no line at all, which is right -- it is theirs, not the group's. **`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types. The profile and the relay lists are the same kind of statement and the transcript does the same thing with both -- a `RitualNotice`, answered and settled, because by the time the line exists a quorum has signed and there is nothing left to do about it. A type missing from that set renders as a chat bubble, silently, looking exactly like a member having said "The group is now called Translation collective"; that is why it is a set with two members rather than two checks. **Relay lists: four sets, and the two that were left out are the interesting part.** General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked (NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not here even though this app writes one for every person: a key package is a device's offer to be added to an MLS group, and a group has no device and joins nothing, so a group advertising where its key packages live would point at an address that is permanently empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason below. **A group signs and cannot decrypt, so every list is written in public tags.** NIP-51 puts a relay list's entries in the encrypted half by default, and a blocked list especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to the author's own key and there is no ECDH for a FROST threshold key here. That rules out 10012 entirely -- its list is only ever private -- and it means the group's blocked list is public where a person's client would keep it secret. Said in the code, and said on the Blocked tab where somebody is about to act on it. **The editor proposes every list that changed at once, which is the deliberate departure from the interface it copies.** Wisp publishes the tab in front of you, because a person signs for themselves and there is nothing to coordinate. Here each signature costs a quorum's attention, and four sessions for one sitting at one screen would ask for it four times over what is plainly one decision. `changedSets` is what keeps that honest, and it is the piece with a quiet failure on both sides: too eager and every visit re-signs four untouched lists, dating a decision the group did not make; too shy and an edit is dropped on a screen that said it had been sent. It compares in order, because a relay list is written in the order it is read back and a member who moved a relay to the front meant to; it counts a seeded first-time list as a change, so a group whose editor filled itself in will actually send it; and it counts an empty unagreed set as no change, so opening Blocked and pressing the button does not put a quorum's signature over silence. Nine cases in `EditGroupRelaysViewModelJvmTest`. **A relay that is neither read nor write is a state NIP-65 cannot express**, so it must not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read` for read-only and `write` for write-only, and has nothing for neither -- what neither would mean is that the relay is not in the list, and removing it is the row's own button. The toggles refuse to turn off the last marker and `template` drops such a relay as a second line of defence, because a bare tag written for it would advertise it as *both*, which is the opposite of what was asked. The round trip is tested for all three markers at once: the ordinary case is the dangerous one, since "both" is encoded by the third element being *absent*, so a reader that dropped it entirely would look correct for a both-ways relay and silently promote every read-only one. **`ephemeral.mantra.press` is what a first-time group is seeded with**, for General, Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish, finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to talk to somebody that nobody in the group chose. **A profile edit builds on the group's last one; a relay list does not.** `template` for the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead of being dropped by it. Using `createNew` there would compile, pass any test that only looked at the six fields on the form, and quietly wipe the rest every time somebody fixed a typo in the group's name. A relay list is the opposite: it is one list, replacing it is the whole point of signing a new one, and carrying a tag forward would make removal impossible. Both are tested for the behaviour that would be silent if wrong. **The name is written to both `name` and `display_name`.** They are the two fields readers pick a name out of and they disagree at their peril: a group whose `display_name` came from some other tool and whose `name` was edited here would keep answering to the old one in every client preferring `display_name` -- an edit that visibly does not take. One field on the form, both keys in the event. **Blank clears, and that is the only way to unsay something a group has signed.** `MetadataEvent`'s rule for these arguments is that an empty string removes the key and null leaves it alone, so the form sends what its fields hold. Said on the screen, because a member emptying a field is entitled to know whether it will be ignored. **Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather than a key it can sign as, so it has no nostr identity and never will; an empty section there would promise something that is not coming. Every other room shows the sections and gates only the two edit buttons on holding a share of the key -- the profile and the relay lists are worth reading whoever is looking, and only a share-holder can open a session about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry points and the subgroup one, since it walks the room's ceremonies looking for a share and asking three times would do the same walk three times for one answer. **"Never agreed" and "agreed empty" are different states and the summary says which.** A set the group has never signed reads "not set yet"; one it signed empty reads "no relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and `GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so the screen has a row per set either way. **The editor's working copy is seeded from the screen, not from the loader**, and that is a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an empty editor while the app worked fine, which is the shape of bug that survives review because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy` is idempotent and fills only missing keys, so the effect that calls it can re-run without throwing away typing. **`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since a group has no `Profile` row to hand it and the picture and the name were all it ever wanted from one. The two existing overloads delegate to it, so there is one avatar rather than a second one for groups. **What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete` applies signed events locally and puts nothing on the wire, for the reason its comment gives. So the profile and the lists are visible to members and to nobody else, and the relay lists say where the group's work *should* go without anything yet sending it there. Publishing would mean a `NostrEvent` row for an event no member authored, which is an architectural decision and belongs in its own change. Said at the top of both readers so the next reader does not assume otherwise. **And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds that a validly signed old one replayed by whoever kept a copy is a statement nobody can refuse. These five kinds are in the same position, so a member who joins after a profile is signed will not be handed it in their catch-up. Adding them is a decision with its own trade and is not made here. 48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape `\'`, so the aapt spelling would render the backslash. 1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`, 41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@4c0ed0c1ceeca5aba461fbca314821befc12c583 --- .../composeResources/values/strings.xml | 48 ++ .../compose/database/model/ChatMessage.kt | 126 ++++ .../model/intermdiate/LocalChatRoom.kt | 3 +- .../repository/DatabaseChatRepository.kt | 37 ++ .../mantra/compose/nostr/GroupNostrProfile.kt | 194 ++++++ .../mantra/compose/nostr/GroupRelayList.kt | 278 +++++++++ .../compose/repository/ChatRepository.kt | 28 + .../ui/composable/ChatRoomDetailScreen.kt | 297 ++++++++- .../composable/EditGroupNostrProfileScreen.kt | 423 +++++++++++++ .../ui/composable/EditGroupRelaysScreen.kt | 577 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 44 ++ .../routes/EditGroupNostrProfileRoute.kt | 10 + .../navigation/routes/EditGroupRelaysRoute.kt | 10 + .../composable/widgets/chat/ChatTranscript.kt | 19 + .../widgets/profile/ProfileAvatar.kt | 35 +- .../ui/view/model/ChatRoomDetailViewModel.kt | 12 +- .../model/EditGroupNostrProfileViewModel.kt | 161 +++++ .../ui/view/model/EditGroupRelaysViewModel.kt | 299 +++++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 37 ++ .../state/EditGroupNostrProfileUIState.kt | 34 ++ .../ui/view/state/EditGroupRelaysUIState.kt | 33 + .../compose/nostr/GroupNostrProfileTest.kt | 438 +++++++++++++ .../compose/nostr/GroupRelayListTest.kt | 398 ++++++++++++ .../GroupNostrProfileSectionJvmTest.kt | 278 +++++++++ .../model/EditGroupRelaysViewModelJvmTest.kt | 224 +++++++ 25 files changed, 4035 insertions(+), 8 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f9ecbcd6..087a4478 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -398,4 +398,52 @@ Check the combined result and confirm it The group has its key — finish setting it up Open + Nostr profile + This group hasn't said anything about itself on Nostr yet. + Edit Nostr profile + Propose Nostr profile + What the group says about itself on Nostr + The group signs this, so it takes a quorum. An empty field unsays what the group had said. + This group has no shared key, so it cannot sign a profile. Run a shared key ceremony first. + Signed by the group on %1$s + No name on this profile + About + Picture url + Website + Nostr address + Lightning address + eg. Translation collective + eg. A group translating hard books into Sesotho + eg. https://example.com/group.png + eg. https://example.com + eg. group@example.com + eg. group@getalby.com + Couldn't ask the group to sign this profile. + Relays + Edit relays + Not set yet + No relays in this list + Where the group lives on Nostr + The group signs each list, so it takes a quorum. Only the lists you change are proposed. + This group has no shared key, so it cannot sign a relay list. Run a shared key ceremony first. + Relay url + eg. wss://relay.example.com + Add relay + That isn't a relay address. Relays start with wss:// and cannot be on this device. + That relay is already in this list. + Propose relays + Nothing has changed, so there is nothing to propose. + Couldn't ask the group to sign these relays. + Read + Write + General + Messages + Search + Blocked + Where the group publishes, and where a reader should look for it. + Where a message addressed to the group should be sent. + The relays a search of the group's work runs against. + Relays the group will not talk to. + A group signs and cannot decrypt, so every list here is public — including the blocked one, which most clients keep private. + Signed %1$s diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index f6db7723..4de3f97f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -20,6 +20,9 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.FrostSigningEvents @@ -383,6 +386,47 @@ data class ChatMessage( */ const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" + /** + * The group signed what it says about itself on nostr. + * + * A system line rather than a bubble, for the reason every group-signed + * line is one: nobody said it, the group signed it. Whoever filled the + * form in proposed it and a quorum agreed, and the transcript of that + * happening is the signing session's own -- this is the line saying what + * the group now is, which is worth having in the room because a rename is + * the kind of thing members should not have to go looking for. + * + * Written only for a profile the *room* signed. A kind:0 authored by + * anybody else that reaches this room is a member's own profile + * travelling as a rumor, which is not the group saying anything. + */ + const val TYPE_GROUP_PROFILE_SIGNED = "groupProfileSigned" + + /** + * The group signed where it lives on nostr. + * + * One line per list, so a session that changed three of them writes three. + * They are not three accounts of one thing -- each names which list was + * agreed, which is the part a reader needs -- and `applyInnerEvent` is + * handed one event at a time with no way to know it was in a batch anyway. + */ + const val TYPE_GROUP_RELAYS_SIGNED = "groupRelaysSigned" + + /** + * Every line about the group's own nostr identity, for the one check the + * transcript dispatches on. + * + * The profile and the relay lists together, because they are the same kind + * of statement -- the group's account of itself, signed by the group -- and + * the transcript does the same thing with both. A type missing from here + * renders as a chat bubble, silently, looking exactly like somebody having + * said "The group is now called Translation collective". + */ + val GROUP_IDENTITY_TYPES = setOf( + TYPE_GROUP_PROFILE_SIGNED, + TYPE_GROUP_RELAYS_SIGNED, + ) + /** * Every subgroup line, for the one check the transcript dispatches on. * @@ -1345,6 +1389,88 @@ data class ChatMessage( ) } + // The group saying who it is on nostr. A room's id is the pubkey + // it signs as, so a group has an identity from the moment it has a + // key; this is the kind:0 describing it, signed by the group's own + // quorum like everything else it says. + // + // **Only when the room itself signed it.** This arm is reached both + // from a completed signing session, where the author is the room by + // construction, and from an arriving inner event, where any member + // could have sent a rumor of this kind -- and it cannot tell which. + // A member's own kind:0 travelling through a room is their profile, + // not the group's, and gets no line here. + // + // No row is written. The event is already on file as a + // `GroupSignedEvent` -- `FrostSigningManager.complete` records the + // batch before it applies it -- and `GroupNostrProfile` reads the + // group's profile straight off those. A `Profile` row is not an + // option anyway: it hangs off `NostrEvent` by foreign key, and a + // group-signed event is not one. + MetadataEvent.KIND -> { + if (!event.pubKey.equals(groupId, ignoreCase = true)) return null + + val profile = GroupNostrProfile.of( + signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId), + chatRoomId = groupId, + ) ?: return null + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_PROFILE_SIGNED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = profile.name()?.let { "The group is now called $it" } + ?: "The group signed a new profile for itself" + ) + } + + // The group saying where it lives on nostr: which relays carry + // its work, take its messages, answer a search of it, and which it + // will not talk to. + // + // **Verified, not just attributed.** This arm is reached from a + // completed signing session, where the author is the room by + // construction, and from an arriving inner event, where it is + // whatever the sender wrote -- and it cannot tell which. A rumor + // carries an empty signature and any pubkey its sender likes, so an + // author check alone would let one member put "the group signed its + // general relay list" in the transcript. `verifies` is what makes + // the line mean what it says: the room's key over these tags. + // + // A member's own relay list travelling through the room fails the + // author half and gets no line, which is right -- it is their list, + // not the group's. + // + // No row is written, and nothing is parsed beyond the check. The + // events are already on file as `GroupSignedEvent` and + // `GroupRelayList` reads the lists straight off those; this arm + // exists so a signed relay list is a line in the transcript rather + // than raw JSON in a bubble. + in GroupRelaySet.KINDS -> { + val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) + if (!signed.verifies()) return null + + val set = GroupRelaySet.entries.firstOrNull { it.kind == event.kind } + ?: return null + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_RELAYS_SIGNED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group signed its ${set.name.lowercase()} relay list" + ) + } + else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt index 9ca34fd7..1d0a7083 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt @@ -117,7 +117,8 @@ data class LocalChatRoom( lastChatMessage.messageType in ChatMessage.FROST_TYPES || lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES || lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES || - lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES + lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES || + lastChatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES ) { val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES || lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index b931ad84..25a629df 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -9,6 +9,9 @@ import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -25,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -120,6 +124,39 @@ class DatabaseChatRepository( null } + override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = try { + GroupNostrProfile.newestAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, MetadataEvent.KIND), + chatRoomId = chatRoomId, + ) + } catch (e: Throwable) { + // Reading one parses content off the wire and checks a signature, and a + // room that shows no profile is wrong and survivable where one that will + // not open is neither -- the same trade the readings above make. + logger.e("Error reading the nostr profile of $chatRoomId", e) + null + } + + override suspend fun groupRelayLists(chatRoomId: String): List = try { + // One query per set rather than one for the room: the four kinds are + // indexed and a room's whole signed history is every artifact, chunk and + // translation it has ever agreed. + GroupRelaySet.entries.map { set -> + GroupRelayList.newestAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, set.kind), + chatRoomId = chatRoomId, + set = set, + ) + } + } catch (e: Throwable) { + logger.e("Error reading the relay lists of $chatRoomId", e) + // Four unsigned, empty sets: the screen draws "not set yet" rather than + // failing to draw, which is the trade every reading above makes. + GroupRelayList.allAmong(emptyList(), chatRoomId) + } + override suspend fun canSign(chatRoomId: String): Boolean = try { FrostSigningManager.canSign(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt new file mode 100644 index 00000000..536b21f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt @@ -0,0 +1,194 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.time.Instant + +/** + * The kind:0 a group signed about itself, as the group signed it. + * + * A Marmot room's id *is* the nostr pubkey it signs as -- see + * `docs/shared-key-derivation.md`, where those are one value -- so the group has + * an identity on nostr whether or not it has ever said anything about it. This is + * what it says: a name, a picture, a line about what the group is for, authored by + * the group rather than by whichever member typed it. + * + * ### Why this reads off [GroupSignedEvent] and not off `Profile` + * + * A `Profile` row hangs off a `NostrEvent` by foreign key, and a group-signed + * event is not a `NostrEvent`: no member sent it, it never travelled on the wire + * as itself, and the outbound pipeline would re-author it as its sender and strip + * the group's signature off. See `GroupSignedEvent`, which exists for exactly the + * events in that position. So the group's profile lives where the group's other + * signed statements live, and this is the reading of it. + * + * The consequence worth stating: **nothing here has reached a relay.** A member's + * kind:0 is published and is how the rest of nostr learns their name; a group's is + * signed and kept, and every device that followed the signing session -- or was + * handed the event afterwards -- holds it. Publishing it would mean a `NostrEvent` + * row for an event no member authored, which is a decision this does not make. + * + * ### What a reading has to earn + * + * [GroupSignedEvent.verifies] and nothing less: the author has to be the room, the + * id has to be the hash of the fields beside it, and the signature has to verify. + * That is what makes this the *group's* profile rather than a kind:0 that happened + * to be filed against the room -- and it is checkable from the row alone, with no + * ceremony or key state to consult first. + * + * A room never derived from its group's key signs as the bare threshold key rather + * than as its own id, so it has no profile *for its own pubkey* and gets none + * here. That is the derivation's limit rather than this reader's, and it is the + * same limit `GroupSignedEvent.verifies` documents. + */ +data class GroupNostrProfile( + /** The group's statement, whole, with the signature that makes it one. */ + val signedEvent: GroupSignedEvent, + /** Its content parsed: the fields nostr clients read a profile out of. */ + val metadata: UserMetadata, +) { + /** The group's nostr identity, which for a derived room is also its id. */ + val publicKey: HexKey get() = signedEvent.publicKey + + /** When the group signed it, which is what decides the newest of two. */ + val signedAt: Instant get() = signedEvent.createdAt + + /** + * What to call the group, or null when it has published no name. + * + * `display_name` before `name`, which is what `UserMetadata.anyName` does and + * what every other nostr client does. Null rather than the pubkey: a caller + * showing this beside the pubkey would otherwise show it twice. + */ + fun name(): String? = metadata.anyName()?.takeIf { it.isNotBlank() } + + fun about(): String? = metadata.about?.takeIf { it.isNotBlank() } + + fun picture(): String? = metadata.picture?.takeIf { it.isNotBlank() } + + fun banner(): String? = metadata.banner?.takeIf { it.isNotBlank() } + + fun website(): String? = metadata.website?.takeIf { it.isNotBlank() } + + fun nip05(): String? = metadata.nip05?.takeIf { it.isNotBlank() } + + fun lud16(): String? = metadata.lud16?.takeIf { it.isNotBlank() } + + /** The event as the group signed it, for [MetadataEvent.updateFromPast]. */ + fun asMetadataEvent(): MetadataEvent = signedEvent.toEvent().let { event -> + MetadataEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + } + + companion object { + /** + * The reading of one signed event, or null when it is not one of these. + * + * Three ways to be null and they are all the same refusal: the wrong kind, + * a signature that does not check out as [chatRoomId]'s, or content that + * will not parse as a profile. A caller gets a profile the group really + * signed or gets nothing. + */ + fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupNostrProfile? { + if (signedEvent.kind != MetadataEvent.KIND) return null + if (!signedEvent.verifies()) return null + + // An empty kind:0 parses to an empty profile rather than to null -- + // wiping a profile is a thing groups are entitled to do, and quartz + // reads it as the blank one it is. Null here is a parse failure. + val metadata = MetadataEvent( + id = signedEvent.id, + pubKey = signedEvent.publicKey, + createdAt = signedEvent.createdAt.epochSeconds, + tags = signedEvent.tags, + content = signedEvent.content, + sig = signedEvent.signature, + ).contactMetaData() ?: return null + + return GroupNostrProfile(signedEvent = signedEvent, metadata = metadata) + } + + /** + * The newest profile [chatRoomId] signed among [signedEvents], or null if + * it signed none. + * + * Newest by the timestamp the group signed at, with the id breaking a tie, + * because kind:0 is replaceable: a group that edits its profile signs a + * second one and the second is the answer. Two devices reading the same + * events in whatever order the queries hand them over have to agree on + * which, and a tie on the second is not rare enough to leave to luck. + * + * Takes the events rather than fetching them so the same question can be + * asked of a query's result or of a flow's emission. + */ + fun newestAmong( + signedEvents: List, + chatRoomId: String, + ): GroupNostrProfile? = + signedEvents + .asSequence() + .mapNotNull { of(it, chatRoomId) } + .maxWithOrNull( + compareBy({ it.signedAt }, { it.signedEvent.id }) + ) + + /** + * The event to ask the group to sign for a profile with these fields. + * + * Built from [latest] where the group has one, so a field this app does not + * offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives + * an edit instead of being dropped by it. That is `updateFromPast`'s whole + * job, and reaching for `createNew` on a group that already has a profile + * would quietly wipe every such field. + * + * Blank means delete, which is `MetadataEvent`'s own rule for these + * arguments: an empty string removes the key and null leaves it alone. The + * screen sends what its fields hold, so clearing one clears it in the + * profile -- which is the only way to *un*-say something a group has said. + * + * [name] is written to both `name` and `display_name`, which are the two + * fields readers pick a name out of and which disagree at their peril. A + * group whose `display_name` was set by some other tool and whose `name` was + * edited here would keep answering to the old one in every client that + * prefers `display_name` -- an edit that visibly does not take. One field on + * the form, both keys in the event. + */ + fun template( + latest: GroupNostrProfile?, + name: String, + about: String, + picture: String, + website: String, + nip05: String, + lud16: String, + ): EventTemplate = latest?.let { + MetadataEvent.updateFromPast( + latest = it.asMetadataEvent(), + name = name, + displayName = name, + about = about, + picture = picture, + website = website, + nip05 = nip05, + lnAddress = lud16, + ) + } ?: MetadataEvent.createNew( + name = name, + displayName = name, + about = about, + picture = picture, + website = website, + nip05 = nip05, + lnAddress = lud16, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt new file mode 100644 index 00000000..82e31bef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt @@ -0,0 +1,278 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import kotlin.time.Instant +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag as Nip51RelayTag +import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag as Nip17RelayTag + +/** + * Where a group lives on nostr, as the group itself has said. + * + * A Marmot room's id is the pubkey it signs as, so the group is an author like any + * other and the same four relay lists apply to it: where it publishes and is read + * from, where its messages arrive, where it can be searched for, and what it will + * not talk to. Each is a replaceable event of its own kind, so each is agreed -- + * and can be missing -- separately. + * + * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the + * reasons `GroupNostrProfile` gives at length: a group-signed event is not a + * `NostrEvent`, and only an event the room itself authored is the group speaking. + * + * ### Nothing here has reached a relay either + * + * The lists say where the group's work *should* go; sending it there is a separate + * job this does not do. See `GroupNostrProfile`, which carries the same caveat for + * the same reason. + * + * ### Public tags only, and not by preference + * + * NIP-51 puts a relay list's entries in the encrypted half by default -- a blocked + * list especially, since who you refuse to talk to is nobody's business. A group + * cannot use it. The encryption is NIP-44 to the author's own key, and there is no + * ECDH for a FROST threshold key here: the group can sign and cannot decrypt. So + * every list is written in public tags, which is a real difference from what a + * person's client would write and is worth knowing before blocking anything. + */ +data class GroupRelayList( + val set: GroupRelaySet, + /** + * The group's statement, or null for a set it has never agreed. + * + * Null and an empty [relays] are different states: never said, versus said and + * said nothing. A group that signs an empty list has withdrawn the one it had. + */ + val signedEvent: GroupSignedEvent?, + val relays: List, +) { + /** When the group signed it, or null for a set it has never agreed. */ + val signedAt: Instant? get() = signedEvent?.createdAt + + /** Whether the group has ever put its key to this set. */ + val isAgreed: Boolean get() = signedEvent != null + + companion object { + /** + * The reading of one signed event as [set], or null when it is not one. + * + * The same three refusals as `GroupNostrProfile.of`: the wrong kind, a + * signature that is not [chatRoomId]'s, or tags that carry no relay. + */ + fun of( + signedEvent: GroupSignedEvent, + chatRoomId: String, + set: GroupRelaySet, + ): GroupRelayList? { + if (signedEvent.kind != set.kind) return null + if (!signedEvent.verifies()) return null + + return GroupRelayList( + set = set, + signedEvent = signedEvent, + relays = set.parse(signedEvent.tags), + ) + } + + /** + * The newest list [chatRoomId] signed for [set] among [signedEvents]. + * + * Never null: a set the group has never agreed reads back as an unsigned, + * empty list rather than as an absence, so a caller always has a row per + * set to show and [isAgreed] is what separates the two. + * + * Newest by the group's own timestamp with the id breaking a tie, for the + * reason `GroupNostrProfile.newestAmong` gives: these are replaceable, and + * two devices reading the same events in different orders have to agree. + */ + fun newestAmong( + signedEvents: List, + chatRoomId: String, + set: GroupRelaySet, + ): GroupRelayList = + signedEvents + .asSequence() + .mapNotNull { of(it, chatRoomId, set) } + .maxWithOrNull(compareBy({ it.signedAt }, { it.signedEvent?.id })) + ?: GroupRelayList(set = set, signedEvent = null, relays = emptyList()) + + /** Every set, in the order the editor shows them. */ + fun allAmong( + signedEvents: List, + chatRoomId: String, + ): List = + GroupRelaySet.entries.map { newestAmong(signedEvents, chatRoomId, it) } + } +} + +/** + * One relay in one of a group's lists. + * + * [read] and [write] mean something only in [GroupRelaySet.General], which is the + * one list NIP-65 gives markers to; everywhere else a relay is simply in the list + * and both are true. They are kept on the shared type rather than split into two + * so that the editor has one row shape and one add path. + * + * **Neither is not a state.** NIP-65 writes a bare `r` tag for both, `read` for + * read-only and `write` for write-only, and has nothing at all for a relay that is + * neither -- a relay you will not read from and will not write to is one you have + * removed. The editor enforces that rather than this dropping it silently. + */ +data class GroupRelay( + val url: NormalizedRelayUrl, + val read: Boolean = true, + val write: Boolean = true, +) { + /** The host, for a list that is read rather than copied. */ + fun displayUrl(): String = url.displayUrl() +} + +/** + * The relay lists a group keeps, and what each one is for. + * + * The four a person's client keeps, because a group is an author like any other and + * these are the four questions the network asks about one. + * + * **Key packages (MIP-00, kind 10051) are deliberately not here**, though this app + * writes one for every person. A key package is a device's offer to be added to an + * MLS group, and a group has no device and joins nothing -- so a group advertising + * where its key packages live would be pointing at a place that will always be + * empty, which is worse than saying nothing. See `MarmotKeyPackage`, which is keyed + * by member. + * + * **Relay feeds (kind 10012) are not here either**, for the harder reason: the list + * lives in the encrypted half and a threshold key cannot decrypt. See + * [GroupRelayList]'s note on public tags. + */ +enum class GroupRelaySet( + val kind: Kind, + /** Whether NIP-65's read and write markers apply, which is General alone. */ + val hasReadWriteMarkers: Boolean = false, +) { + /** + * NIP-65: where the group publishes and where a reader should look for it. + * + * The one that matters most and the only one with markers. Everything the group + * signs is addressed to whoever wants to read it, and this is the answer to + * where. + */ + General(AdvertisedRelayListEvent.KIND, hasReadWriteMarkers = true), + + /** NIP-17: where a message addressed to the group's key should be sent. */ + Messages(ChatMessageRelayListEvent.KIND), + + /** NIP-50: the relays the group would have a search of its work run against. */ + Search(SearchRelayListEvent.KIND), + + /** NIP-51: relays the group will not talk to, whatever else says otherwise. */ + Blocked(BlockedRelayListEvent.KIND), + ; + + /** + * What a group with nothing agreed starts the editor at. + * + * The app's own relay, which is where everything else this build publishes and + * reads already goes -- see `Relays.ephemeral`. A group seeded with nothing + * would be a group whose first proposal is an empty list, which says less than + * having never said anything at all. + * + * Blocked is the exception and starts empty on purpose: a default there is a + * refusal to talk to somebody that nobody in the group chose. + */ + fun defaults(): List = when (this) { + Blocked -> emptyList() + else -> listOf(GroupRelay(Relays.ephemeral)) + } + + /** The relays out of a signed event's tags, in the order the group wrote them. */ + fun parse(tags: Array>): List = when (this) { + General -> tags.mapNotNull(AdvertisedRelayInfo::parse).map { + GroupRelay( + url = it.relayUrl, + read = it.type.isRead(), + write = it.type.isWrite(), + ) + } + // Both nip51 lists and NIP-17's use a "relay" tag, and the two RelayTag + // classes that parse it are different classes in different packages. Named + // apart at the import so a reader can see which NIP each line is following. + Messages -> tags.mapNotNull(Nip17RelayTag::parse).map { GroupRelay(it) } + Search, Blocked -> tags.mapNotNull(Nip51RelayTag::parse).map { GroupRelay(it) } + }.distinctBy { it.url } + + /** + * The event to ask the group to sign for this set. + * + * Built from scratch rather than from the group's last one, which is the + * opposite of what `GroupNostrProfile.template` does and for a reason: a + * profile is a bag of independent fields where an unknown one is worth keeping, + * and a relay list is one list where the whole point of signing a new one is to + * replace it. Carrying a tag forward here would make a removal impossible. + * + * A relay that is neither read nor write is dropped rather than written, since + * NIP-65 cannot express one -- the editor does not allow the state, and this is + * the second line of that defence rather than the first. + */ + fun template(relays: List): EventTemplate<*> { + val tags = when (this) { + General -> relays.mapNotNull { relay -> + val type = when { + relay.read && relay.write -> AdvertisedRelayType.BOTH + relay.read -> AdvertisedRelayType.READ + relay.write -> AdvertisedRelayType.WRITE + else -> return@mapNotNull null + } + AdvertisedRelayInfo.assemble(relay.url, type) + } + Messages -> relays.map { Nip17RelayTag.assemble(it.url) } + Search, Blocked -> relays.map { Nip51RelayTag.assemble(it.url) } + } + + // Empty content throughout. For General and Messages that is all the kind + // has; for the two nip51 lists it is where the encrypted half would go, and + // a group has no encrypted half. See [GroupRelayList]. + return EventTemplate( + createdAt = kotlin.time.Clock.System.now().epochSeconds, + kind = kind, + tags = tags.toTypedArray(), + content = "", + ) + } + + companion object { + /** + * The kinds a group's relay lists are written as. + * + * A set rather than a walk of [entries], for `ChatMessage.applyInnerEvent`, + * whose dispatch is one `when` over an event's kind -- and a `when` branch + * has to be a constant expression rather than a predicate over an enum. + */ + val KINDS: Set = entries.map { it.kind }.toSet() + + /** + * [url] as a relay a group could actually be told to use, or null. + * + * Structural only, and deliberately narrow: `wss://` because a group's + * signed list is read by strangers over the open network and `ws://` would + * ask them to fetch it in the clear, and no loopback because a relay only + * this device can reach is not something to put a quorum's signature on. + * Wisp's `RelayConfig.isValidUrl` refuses the same three things. + */ + fun relayUrlOrNull(url: String): NormalizedRelayUrl? { + val trimmed = url.trim() + if (!trimmed.startsWith("wss://", ignoreCase = true)) return null + + return RelayUrlNormalizer.normalizeOrNull(trimmed)?.takeUnless { it.isLocalHost() } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 8fbfa95e..71fd8fea 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -9,6 +9,8 @@ import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -79,6 +81,27 @@ interface ChatRepository { */ suspend fun parentOf(chatRoomId: String): HexKey? + /** + * The kind:0 this group signed about itself, or null while it has signed + * none. + * + * The group's identity on nostr is the room's own id, so it has one to + * describe whether or not it ever has. Read off the group's signed events and + * verified there -- see `GroupNostrProfile` -- so a room shows the profile its + * own key signed or shows that it has none. + */ + suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? + + /** + * Where this group has said it lives on nostr: one entry per relay set, always + * all of them. + * + * A set the group has never agreed comes back unsigned and empty rather than + * missing, because "never said" is a state the screen has to show and a gap in + * a list is not one. See `GroupRelayList.isAgreed`. + */ + suspend fun groupRelayLists(chatRoomId: String): List + /** * Whether this device holds a share of the group's key, and so could take * part in signing for it. @@ -260,6 +283,11 @@ interface ChatRepository { override suspend fun parentOf(chatRoomId: String): HexKey? = null + override suspend fun groupNostrProfile(chatRoomId: String): GroupNostrProfile? = null + + override suspend fun groupRelayLists(chatRoomId: String): List = + GroupRelayList.allAmong(emptyList(), chatRoomId) + override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun refuseSubgroup( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index e816e78b..6dce2aef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.material.icons.filled.Autorenew import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks import androidx.compose.material.icons.filled.PersonAdd @@ -69,6 +71,8 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.extensions.toFormattedTimeAndDateString @@ -81,11 +85,26 @@ import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members +import mantra.composeapp.generated.resources.nostr_profile +import mantra.composeapp.generated.resources.relays +import mantra.composeapp.generated.resources.edit_relays +import mantra.composeapp.generated.resources.not_set_yet +import mantra.composeapp.generated.resources.no_relays_in_this_list +import mantra.composeapp.generated.resources.relay_set_general +import mantra.composeapp.generated.resources.relay_set_messages +import mantra.composeapp.generated.resources.relay_set_search +import mantra.composeapp.generated.resources.relay_set_blocked +import mantra.composeapp.generated.resources.no_name_on_this_profile +import mantra.composeapp.generated.resources.edit_nostr_profile +import mantra.composeapp.generated.resources.signed_by_the_group_on +import mantra.composeapp.generated.resources.this_group_has_not_said_anything_about_itself import mantra.composeapp.generated.resources.parent_group import mantra.composeapp.generated.resources.a_subgroup import mantra.composeapp.generated.resources.created_you_are_not_a_member @@ -97,6 +116,9 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.add_artifact_to_library @@ -314,6 +336,123 @@ fun ChatRoomDetailScreen( HorizontalDivider() } + // Under the room's identity and above its work, because + // that is what it is: what the rest of nostr reads for + // the key the signing key row names. The room's subject + // and description above are this device's names for it; + // this is the group's own, signed by the group. + // + // Marmot rooms only, and absent rather than empty in a + // NIP-17 one. A NIP-17 room's id is not a key it can + // sign as, so it has no nostr identity at all -- and a + // section saying it has not described one yet would + // promise something that is never coming. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { + item { + Text( + text = stringResource(Res.string.nostr_profile), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + chatRoomDetailUIState.groupNostrProfile?.let { groupNostrProfile -> + GroupNostrProfileCard( + groupNostrProfile = groupNostrProfile, + publicKey = chatRoomId + ) + } ?: Text( + stringResource( + Res.string.this_group_has_not_said_anything_about_itself + ) + ) + } + + // The profile is worth reading either way; proposing + // one is a signature by the group, and + // `proposeSigningBatch` throws for a device holding + // no share of the key. Hidden rather than disabled, + // the way the subgroup entry is. + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + EditGroupNostrProfileRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Badge, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.edit_nostr_profile)) + } + } + } + + // Directly under the profile, because between them + // they are the whole of the group's account of + // itself on nostr: who it says it is, and where it + // says it can be found. Same gate, same reasons. + item { + Text( + text = stringResource(Res.string.relays), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + GroupRelayListsCard( + relayLists = chatRoomDetailUIState.groupRelayLists + ) + } + + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + EditGroupRelaysRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Dns, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.edit_relays)) + } + } + } + + item { + HorizontalDivider() + } + } + item { // Artifacts Text( @@ -1056,6 +1195,135 @@ internal fun GroupKeyStateSheetContent( * signed event, which is not grouped -- so the display can be shaped for reading * without a paste losing the value. */ +/** + * What the group says about itself on nostr, as the group signed it. + * + * A preview rather than a profile screen: the name, the picture, the nostr address + * and the first lines of the group's own account of itself, which is what a member + * opening the group's details came here to check. The whole thing is one signed + * event and the proposal list is where it can be read out in full. + * + * **The picture and the name come from the metadata; the tint comes from the key.** + * [publicKey] is the room's id, which is the pubkey the group signs as, so a group + * with no picture yet still gets the colour it has everywhere else in the app. + * + * The signing date is here because a group's profile is standing state with no + * other clue to its age -- and because the alternative reading, that this arrived + * from a relay a moment ago, is exactly what has not happened. Nothing on this card + * has been published: see `GroupNostrProfile`. + */ +@Composable +private fun GroupNostrProfileCard( + groupNostrProfile: GroupNostrProfile, + publicKey: HexKey +) { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = publicKey, + picture = groupNostrProfile.picture(), + name = groupNostrProfile.name() + ) + }, + headlineContent = { + Text( + // Named rather than falling back to the pubkey: the signing key + // row above already shows that, and showing it twice would say + // the group has a name when it has not. + text = groupNostrProfile.name() + ?: stringResource(Res.string.no_name_on_this_profile), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + groupNostrProfile.nip05()?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + groupNostrProfile.about()?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.signed_by_the_group_on, + groupNostrProfile.signedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +/** + * Where the group has said it lives on nostr, one line per relay list. + * + * One card holding four rows rather than four cards: they are read together -- the + * question is whether the group is reachable, not what any single list says -- and + * four cards of one line each on a screen this long is four dividers to scroll past + * for the same four facts. + * + * **A list the group has never agreed reads "not set yet", and an agreed empty one + * reads "no relays".** Those are different things: one is a group that has not got + * round to it, and the other is a group that decided. Collapsing them would hide a + * deliberate withdrawal behind an oversight. + */ +@Composable +private fun GroupRelayListsCard(relayLists: List) { + Card(modifier = Modifier.fillMaxWidth()) { + relayLists.forEach { list -> + ListItem( + leadingContent = { + Icon(Icons.Default.Dns, contentDescription = Decorative) + }, + headlineContent = { + Text(text = stringResource(list.set.summaryLabel())) + }, + supportingContent = { + Text( + text = when { + !list.isAgreed -> stringResource(Res.string.not_set_yet) + list.relays.isEmpty() -> + stringResource(Res.string.no_relays_in_this_list) + // Hosts rather than URLs: every one of them starts + // `wss://`, so the scheme is the part that never + // distinguishes two entries. + else -> list.relays.joinToString { it.displayUrl() } + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } +} + +/** The name of a relay list, for the summary on the group's screen. */ +private fun GroupRelaySet.summaryLabel() = when (this) { + GroupRelaySet.General -> Res.string.relay_set_general + GroupRelaySet.Messages -> Res.string.relay_set_messages + GroupRelaySet.Search -> Res.string.relay_set_search + GroupRelaySet.Blocked -> Res.string.relay_set_blocked +} + /** * One subgroup, as the parent's record and this device's rooms together have it. * @@ -1238,7 +1506,10 @@ private fun ChatRoomMessagingScreenPreview() { subject = "Message title", description = "Description of a chat room so that members now why they are here.", initialGiftWrapPayloadId = "sdfaer", - mlsGroupState = null + // A Marmot room, so the nostr profile section renders + // at all -- it is hidden in a NIP-17 one, which has no + // key to have an identity on nostr. + mlsGroupState = "state" ), localParticipants = listOf( LocalParticipant( @@ -1266,7 +1537,29 @@ private fun ChatRoomMessagingScreenPreview() { derivationPath = "m/9420/0/0", announcedBy = "d".repeat(64), announcedAt = Instant.fromEpochSeconds(1_700_000_000) - ) + ), + // A group that has described itself, so the section renders + // with something in it rather than only in its empty state. + // Built directly rather than read out of a signed event: the + // reading checks a signature, and there is no real one here. + groupNostrProfile = GroupNostrProfile( + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = "publicKey", + publicKey = "publicKey", + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + metadata = UserMetadata().apply { + displayName = "Translation collective" + about = "A group translating hard books into Sesotho." + nip05 = "group@example.com" + } + ), + canEditNostrProfile = true ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt new file mode 100644 index 00000000..33e8d287 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupNostrProfileScreen.kt @@ -0,0 +1,423 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Title +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.EditGroupNostrProfileViewModel +import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.about +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_profile +import mantra.composeapp.generated.resources.edit_nostr_profile +import mantra.composeapp.generated.resources.eg_a_group_translating_hard_books +import mantra.composeapp.generated.resources.eg_group_example_com +import mantra.composeapp.generated.resources.eg_group_getalby_com +import mantra.composeapp.generated.resources.eg_https_example_com +import mantra.composeapp.generated.resources.eg_https_example_com_group_png +import mantra.composeapp.generated.resources.eg_translation_collective +import mantra.composeapp.generated.resources.lightning_address +import mantra.composeapp.generated.resources.name +import mantra.composeapp.generated.resources.nostr_address +import mantra.composeapp.generated.resources.picture_url +import mantra.composeapp.generated.resources.propose_nostr_profile +import mantra.composeapp.generated.resources.the_group_signs_its_profile_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_profile +import mantra.composeapp.generated.resources.website +import mantra.composeapp.generated.resources.what_the_group_says_about_itself_on_nostr +import org.jetbrains.compose.resources.stringResource + +/** + * The form for what a group says about itself on nostr. + * + * A group's kind:0 is signed by the group, so pressing the button proposes rather + * than saves: the form goes out as one event for the room's quorum to put its key + * to, and the screen hands over to the signing session it opened. Nothing about the + * profile has changed by the time this screen closes. + * + * The fields start at what the group already says, so an edit is an edit. And they + * are sent as they stand -- **an emptied field clears what the group had said**, + * which is the only way to unsay something a group has signed. Fields this form + * does not offer are carried across untouched; see `GroupNostrProfile.template`. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun EditGroupNostrProfileScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState = + EditGroupNostrProfileUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val editGroupNostrProfileViewModel: EditGroupNostrProfileViewModel = viewModel( + factory = EditGroupNostrProfileViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in the click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so the message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_profile) + + ScreenStateTransition(editGroupNostrProfileViewModel.editGroupNostrProfileUIState) { uiState -> + when (val editGroupNostrProfileUIState = uiState) { + is EditGroupNostrProfileUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState( + message = editGroupNostrProfileUIState.message, + onRetry = null + ) + } + + is EditGroupNostrProfileUIState.Loaded -> { + val profile = editGroupNostrProfileUIState.groupNostrProfile + + // Seeded from the profile the group already signed, so the form is + // an edit of it. `rememberTextFieldState` saves what is typed, which + // is what survives a rotation with the edits intact. + val nameFieldState = rememberTextFieldState(profile?.name() ?: "") + val aboutFieldState = rememberTextFieldState(profile?.about() ?: "") + val pictureFieldState = rememberTextFieldState(profile?.picture() ?: "") + val websiteFieldState = rememberTextFieldState(profile?.website() ?: "") + val nip05FieldState = rememberTextFieldState(profile?.nip05() ?: "") + val lud16FieldState = rememberTextFieldState(profile?.lud16() ?: "") + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + val canSign = editGroupNostrProfileUIState.canSign + + // imePadding: this screen is nothing but text fields, and without it + // the software keyboard covers whichever one is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + editGroupNostrProfileUIState.localChatRoom + .RenderChatRoomTitleText() + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + editGroupNostrProfileViewModel.proposeNostrProfile( + localChatRoom = + editGroupNostrProfileUIState.localChatRoom, + groupNostrProfile = profile, + nameField = nameFieldState, + aboutField = aboutFieldState, + pictureField = pictureFieldState, + websiteField = websiteFieldState, + nip05Field = nip05FieldState, + lud16Field = lud16FieldState, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. The profile has not + // changed yet -- it changes when a + // quorum signs -- so landing on a + // group still showing the old one + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // Stay on the form with what was typed + // still in it. The proposal is what + // failed, and every word of it is worth + // keeping for the retry. + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon( + Icons.Default.Draw, + contentDescription = "Propose nostr profile" + ) + Text(stringResource(Res.string.propose_nostr_profile)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.what_the_group_says_about_itself_on_nostr)) + + Text( + text = stringResource( + Res.string.the_group_signs_its_profile_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_a_profile + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + NostrProfileField( + state = nameFieldState, + icon = Icons.Default.Title, + iconDescription = "Name of the group", + label = stringResource(Res.string.name), + placeholder = stringResource(Res.string.eg_translation_collective) + ) + + NostrProfileField( + state = aboutFieldState, + icon = Icons.AutoMirrored.Filled.Notes, + iconDescription = "What the group is for", + label = stringResource(Res.string.about), + placeholder = stringResource( + Res.string.eg_a_group_translating_hard_books + ), + // The one field with prose in it, and the only one a + // single line would truncate while it was being typed. + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 3) + ) + + NostrProfileField( + state = pictureFieldState, + icon = Icons.Default.Image, + iconDescription = "Picture of the group", + label = stringResource(Res.string.picture_url), + placeholder = stringResource(Res.string.eg_https_example_com_group_png) + ) + + NostrProfileField( + state = websiteFieldState, + icon = Icons.Default.Link, + iconDescription = "Website of the group", + label = stringResource(Res.string.website), + placeholder = stringResource(Res.string.eg_https_example_com) + ) + + NostrProfileField( + state = nip05FieldState, + icon = Icons.Default.AlternateEmail, + iconDescription = "Nostr address of the group", + label = stringResource(Res.string.nostr_address), + placeholder = stringResource(Res.string.eg_group_example_com) + ) + + NostrProfileField( + state = lud16FieldState, + icon = Icons.Default.Bolt, + iconDescription = "Lightning address of the group", + label = stringResource(Res.string.lightning_address), + placeholder = stringResource(Res.string.eg_group_getalby_com) + ) + } + } + } + + EditGroupNostrProfileUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.edit_nostr_profile), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialEditGroupNostrProfileUIState == EditGroupNostrProfileUIState.Loading) { + editGroupNostrProfileViewModel.initiateEditGroupNostrProfile() + } + } +} + +/** + * One field of the profile, in the shape the app's other forms use. + * + * Six of these on one screen is what makes it worth a function: the borderless + * outlined field over the bottom bar's tint is four lines of colours apiece, and + * six copies of that is where a divergence hides. + */ +@Composable +private fun NostrProfileField( + state: TextFieldState, + icon: ImageVector, + iconDescription: String, + label: String, + placeholder: String, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine +) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), + state = state, + lineLimits = lineLimits, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(icon, contentDescription = iconDescription) + }, + label = { + Text(text = label) + }, + placeholder = { + Text(text = placeholder) + } + ) +} + +@ConformancePreviews +@Composable +private fun EditGroupNostrProfileScreenPreview() { + MantraTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + EditGroupNostrProfileScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialEditGroupNostrProfileUIState = EditGroupNostrProfileUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation collective", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // A group that can sign, so the form renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt new file mode 100644 index 00000000..6a0003f3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupRelaysScreen.kt @@ -0,0 +1,577 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.EditGroupRelaysViewModel +import press.mantra.compose.ui.view.state.EditGroupRelaysUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.add_relay +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_these_relays +import mantra.composeapp.generated.resources.edit_relays +import mantra.composeapp.generated.resources.eg_wss_relay_example_com +import mantra.composeapp.generated.resources.no_relays_in_this_list +import mantra.composeapp.generated.resources.nothing_has_changed_to_propose +import mantra.composeapp.generated.resources.propose_relays +import mantra.composeapp.generated.resources.relay_read +import mantra.composeapp.generated.resources.relay_set_blocked +import mantra.composeapp.generated.resources.relay_set_blocked_purpose +import mantra.composeapp.generated.resources.relay_set_general +import mantra.composeapp.generated.resources.relay_set_general_purpose +import mantra.composeapp.generated.resources.relay_set_messages +import mantra.composeapp.generated.resources.relay_set_messages_purpose +import mantra.composeapp.generated.resources.relay_set_search +import mantra.composeapp.generated.resources.relay_set_search_purpose +import mantra.composeapp.generated.resources.relay_url +import mantra.composeapp.generated.resources.relay_write +import mantra.composeapp.generated.resources.relays_are_public_a_group_cannot_encrypt +import mantra.composeapp.generated.resources.that_is_not_a_relay_address +import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list +import mantra.composeapp.generated.resources.the_group_signs_each_list_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_relays +import mantra.composeapp.generated.resources.where_the_group_lives_on_nostr +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** + * The four relay lists a group keeps, edited as one sitting. + * + * A tab per list, a field to add one, a row per relay with a way to drop it, and -- + * in the one list NIP-65 gives markers to -- a read and a write chip. The shape is + * borrowed from Wisp's relay screen, which is the interface this app was asked to + * match, with one deliberate departure. + * + * **The button proposes rather than publishes.** Wisp signs with the user's own key + * and a tab's publish button is done the moment it is pressed. A group signs with a + * quorum, so what leaves this screen is a proposal, and the screen hands over to + * the session it opened. Nothing has changed until enough members sign. + * + * And it proposes **every list that changed at once**, rather than the tab in front + * of you. Four sessions for one sitting would ask a quorum the same question four + * times over what is plainly one decision -- see `EditGroupRelaysViewModel`, which + * carries the reasoning and works out what actually differs. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun EditGroupRelaysScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupRelaysUIState: EditGroupRelaysUIState = EditGroupRelaysUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val editGroupRelaysViewModel: EditGroupRelaysViewModel = viewModel( + factory = EditGroupRelaysViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupRelaysUIState = initialEditGroupRelaysUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in a click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_these_relays) + val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose) + + ScreenStateTransition(editGroupRelaysViewModel.editGroupRelaysUIState) { uiState -> + when (val editGroupRelaysUIState = uiState) { + is EditGroupRelaysUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState(message = editGroupRelaysUIState.message, onRetry = null) + } + + is EditGroupRelaysUIState.Loaded -> { + val urlFieldState = rememberTextFieldState() + val selectedSet = editGroupRelaysViewModel.selectedSet + val canSign = editGroupRelaysUIState.canSign + + // The editor's working copy comes from the state rather than from + // whatever loaded it, so a screen handed a loaded state -- a + // preview, a layout test -- fills in the same as the app does. + // Seeding leaves edits alone, so re-running this changes nothing. + LaunchedEffect(editGroupRelaysUIState.signed) { + editGroupRelaysViewModel.seedWorkingCopy(editGroupRelaysUIState.signed) + } + + // A refusal is about what is in the field, so it goes the moment + // the field changes rather than sitting under a URL that has since + // been corrected. + LaunchedEffect(urlFieldState) { + snapshotFlow { urlFieldState.text.toString() } + .collect { editGroupRelaysViewModel.clearAddFailure() } + } + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + editGroupRelaysUIState.localChatRoom.RenderChatRoomTitleText() + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + editGroupRelaysViewModel.proposeRelayLists( + localChatRoom = + editGroupRelaysUIState.localChatRoom, + signed = editGroupRelaysUIState.signed, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. The lists have not + // changed yet -- they change when a + // quorum signs -- so landing on a + // group still showing the old ones + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // Both stay on the form with the edits + // still in it. One is a question and + // one is a failure, and neither is a + // reason to throw away the typing. + onNothingToPropose = { notify(nothingChanged) }, + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon( + Icons.Default.Draw, + contentDescription = "Propose relays" + ) + Text(stringResource(Res.string.propose_relays)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + PrimaryScrollableTabRow( + modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), + edgePadding = 10.dp, + selectedTabIndex = selectedSet.ordinal, + ) { + GroupRelaySet.entries.forEach { set -> + Tab( + selected = set == selectedSet, + onClick = { editGroupRelaysViewModel.selectSet(set) }, + text = { Text(text = stringResource(set.label())) } + ) + } + } + + Column( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.itemGap + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(Res.string.where_the_group_lives_on_nostr), + style = MaterialTheme.typography.titleSmall + ) + + // What this tab's list is actually for. Four relay lists + // is four questions nobody can be expected to hold apart + // from their names alone. + Text( + text = stringResource(selectedSet.purpose()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource( + Res.string.the_group_signs_each_list_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (selectedSet == GroupRelaySet.Blocked) { + // Said where it matters. A person's client keeps + // this list encrypted, and somebody blocking a relay + // here should know the group cannot. + Text( + text = stringResource( + Res.string.relays_are_public_a_group_cannot_encrypt + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_relays + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = urlFieldState, + lineLimits = TextFieldLineLimits.SingleLine, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon(Icons.Default.Dns, contentDescription = Decorative) + }, + label = { Text(stringResource(Res.string.relay_url)) }, + placeholder = { + Text(stringResource(Res.string.eg_wss_relay_example_com)) + }, + isError = editGroupRelaysViewModel.addFailure != null + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + IconButton( + onClick = { + if ( + editGroupRelaysViewModel.addRelay( + urlFieldState.text.toString() + ) + ) { + urlFieldState.clearText() + } + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Add relay" + ) + } + } + + // Named rather than silent. An add button that does + // nothing for a URL it refuses is indistinguishable from + // a missed tap. + editGroupRelaysViewModel.addFailure?.let { failure -> + Text( + text = when (failure) { + EditGroupRelaysViewModel.AddFailure.NotARelay -> + stringResource(Res.string.that_is_not_a_relay_address) + EditGroupRelaysViewModel.AddFailure.AlreadyListed -> + stringResource( + Res.string.that_relay_is_already_in_this_list + ) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + val relays = editGroupRelaysViewModel.relaysOf(selectedSet) + + if (relays.isEmpty()) { + EmptyState( + message = stringResource(Res.string.no_relays_in_this_list), + icon = Icons.Default.Dns + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.screenMargin) + ) { + items(items = relays, key = { it.url.url }) { relay -> + RelayRow( + relay = relay, + hasReadWriteMarkers = selectedSet.hasReadWriteMarkers, + onToggleRead = { + editGroupRelaysViewModel + .toggleRead(selectedSet, relay) + }, + onToggleWrite = { + editGroupRelaysViewModel + .toggleWrite(selectedSet, relay) + }, + onRemove = { + editGroupRelaysViewModel + .removeRelay(selectedSet, relay) + } + ) + } + } + } + } + } + } + + EditGroupRelaysUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.edit_relays), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialEditGroupRelaysUIState == EditGroupRelaysUIState.Loading) { + editGroupRelaysViewModel.initiateEditGroupRelays() + } + } +} + +/** + * The tab's name, and the sentence saying what its list decides. + * + * Kept here rather than on [GroupRelaySet] so the enum stays what it is -- four + * nostr kinds and how to read and write them -- with no catalogue behind it. A + * `when` rather than a field on each entry, so adding a set is a compile error here + * rather than a tab with no name. + */ +private fun GroupRelaySet.label(): StringResource = when (this) { + GroupRelaySet.General -> Res.string.relay_set_general + GroupRelaySet.Messages -> Res.string.relay_set_messages + GroupRelaySet.Search -> Res.string.relay_set_search + GroupRelaySet.Blocked -> Res.string.relay_set_blocked +} + +private fun GroupRelaySet.purpose(): StringResource = when (this) { + GroupRelaySet.General -> Res.string.relay_set_general_purpose + GroupRelaySet.Messages -> Res.string.relay_set_messages_purpose + GroupRelaySet.Search -> Res.string.relay_set_search_purpose + GroupRelaySet.Blocked -> Res.string.relay_set_blocked_purpose +} + +/** + * One relay of one list. + * + * The read and write chips only in [hasReadWriteMarkers], which is NIP-65 alone -- + * the other three kinds have a relay in the list or not, and a chip there would + * offer a distinction the event cannot carry. + */ +@Composable +private fun RelayRow( + relay: GroupRelay, + hasReadWriteMarkers: Boolean, + onToggleRead: () -> Unit, + onToggleWrite: () -> Unit, + onRemove: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + // The host rather than the whole URL: every relay here starts + // `wss://` -- nothing else is allowed in -- so the scheme is four + // characters of nothing in front of the part that differs. + text = relay.displayUrl(), + style = MaterialTheme.typography.bodyMedium + ) + + if (hasReadWriteMarkers) { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + FilterChip( + selected = relay.read, + onClick = onToggleRead, + label = { Text(stringResource(Res.string.relay_read)) } + ) + FilterChip( + selected = relay.write, + onClick = onToggleWrite, + label = { Text(stringResource(Res.string.relay_write)) } + ) + } + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove relay", + tint = MaterialTheme.colorScheme.error + ) + } + } +} + +@ConformancePreviews +@Composable +private fun EditGroupRelaysScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + EditGroupRelaysScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // Unsigned lists, which is a group opening this for the first + // time -- so the editor fills in from `GroupRelaySet.defaults` + // and the preview shows the relay a new group actually starts + // with rather than an empty tab. + signed = GroupRelayList.allAmong(emptyList(), "publicKey"), + // A group that can sign, so the form renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 2cdc39de..7273e49d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -40,6 +40,8 @@ import press.mantra.compose.ui.composable.DkgJoinApprovalScreen import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen +import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen +import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen import press.mantra.compose.ui.composable.KeyPackageManagementScreen @@ -122,6 +124,8 @@ import press.mantra.compose.database.repository.DatabaseMantraRepository import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute @@ -968,6 +972,46 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + EditGroupNostrProfileScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the form so back returns to the group rather than + // to an edit whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + EditGroupRelaysScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the editor so back returns to the group rather + // than to lists whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt new file mode 100644 index 00000000..426ce90c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupNostrProfileRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class EditGroupNostrProfileRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt new file mode 100644 index 00000000..5c0fe7f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupRelaysRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class EditGroupRelaysRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index 75db2d21..730d8046 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -340,6 +340,25 @@ fun ChatTranscript( return@items } + // The group's own profile, signed. Same treatment + // and for the same reason: nobody said it. Answered + // and settled because it reports rather than asks -- + // a quorum has already signed by the time this line + // exists. + // + // It leads nowhere. What a reader wants from one is + // the profile, and the nostr profile section of the + // group's detail screen is where that lives. + if (localChatMessage.chatMessage.messageType in ChatMessage.GROUP_IDENTITY_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = true, + isSettled = true, + onClick = {} + ) + return@items + } + // Signing lines are the same kind of thing and get // the same treatment -- nobody said them either -- // but they lead somewhere else, because what a diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt index d0b1a198..d5c42b01 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt @@ -43,6 +43,35 @@ fun ProfileAvatar( shape: Shape = CircleShape, publicKey: String, profile: press.mantra.compose.database.model.Profile? +) { + ProfileAvatar( + size = size, + shape = shape, + publicKey = publicKey, + picture = profile?.picture, + name = profile?.displayName + ) +} + +/** + * The same avatar for a subject that has no `Profile` row. + * + * A group's is the case in hand: its kind:0 is signed by the group and kept as a + * `GroupSignedEvent`, which is not a `NostrEvent` and so cannot have a `Profile` + * hanging off it -- see `GroupNostrProfile`. The picture and the name are all this + * ever wanted from a profile, so they are what it takes. + * + * [name] only reaches the content description. The tint behind a missing picture + * comes from [publicKey], which is what keeps the same subject the same colour + * everywhere it appears. + */ +@Composable +fun ProfileAvatar( + size: Dp = 55.dp, + shape: Shape = CircleShape, + publicKey: String, + picture: String?, + name: String? = null ) { val modifier = Modifier.size(size).clip(shape = shape) @@ -50,7 +79,7 @@ fun ProfileAvatar( publicKey ) - if (profile?.picture == null) { + if (picture == null) { Icon( modifier = modifier, imageVector = Icons.Default.AccountCircle, @@ -77,9 +106,9 @@ fun ProfileAvatar( ) AsyncImage( - model = profile.picture, + model = picture, modifier = modifier, - contentDescription = "Profile picture for ${profile.displayName ?: profile.publicKey}", + contentDescription = "Profile picture for ${name ?: publicKey}", placeholder = placeHolderGraphic, fallback = fallbackGraphic, error = errorGraphic, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 3bfb3db2..9a807bf7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -74,6 +74,12 @@ class ChatRoomDetailViewModel( // Initiate new chat... and navigate to new chat... ChatRoomDetailUIState.Error("Missing chat room") } else { + // One read of the capability for the two things that need it. It + // walks the room's ceremonies looking for a share, so asking twice + // would do the same walk twice for one answer. + val canSign = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId) + ChatRoomDetailUIState.Loaded( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), @@ -82,8 +88,10 @@ class ChatRoomDetailViewModel( signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), parentChatRoomId = chatRepository.parentOf(chatRoomId), - canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && - chatRepository.canSign(chatRoomId), + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + groupRelayLists = chatRepository.groupRelayLists(chatRoomId), + canEditNostrProfile = canSign, + canAddSubgroup = canSign, ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt new file mode 100644 index 00000000..ff8d81f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupNostrProfileViewModel.kt @@ -0,0 +1,161 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupNostrProfileUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Asks the group to sign what it says about itself on nostr. + * + * The room's id is the pubkey the group signs as, so the kind:0 this proposes is + * the profile the rest of nostr would read for that key -- authored by the group + * rather than by whoever filled the form in. Nothing is saved when the button is + * pressed: what goes out is a proposal, and the profile changes on every member's + * device at once when a quorum has signed it. + */ +class EditGroupNostrProfileViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var editGroupNostrProfileUIState: EditGroupNostrProfileUIState by mutableStateOf( + initialEditGroupNostrProfileUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateEditGroupNostrProfile() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + editGroupNostrProfileUIState = if (localChatRoom == null) { + EditGroupNostrProfileUIState.Error("Couldn't find the chat room") + } else { + EditGroupNostrProfileUIState.Loaded( + localChatRoom = localChatRoom, + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + // A NIP-17 room has no key of its own to sign as, so it has no + // nostr identity to describe -- the same condition the group + // detail screen gates the entry point on. + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Opens a session over one kind:0 for the group's own key. + * + * A blank field is not a field left alone: it clears what the group had said, + * because that is the only way to unsay it. [GroupNostrProfile.template] hands + * every field to `MetadataEvent`, whose rule for these arguments is that empty + * deletes the key -- and it builds on the group's newest profile, so a field + * this form does not offer is carried across rather than wiped. + * + * The fields are not cleared on success, unlike the add forms': a profile is + * standing state rather than a thing being added, and this screen is replaced + * by the signing session anyway. + */ + fun proposeNostrProfile( + localChatRoom: LocalChatRoom, + groupNostrProfile: GroupNostrProfile?, + nameField: TextFieldState, + aboutField: TextFieldState, + pictureField: TextFieldState, + websiteField: TextFieldState, + nip05Field: TextFieldState, + lud16Field: TextFieldState, + onSuccess: (sessionId: String) -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits from repeated taps. A second session over a + // second kind:0 would leave the group's profile decided by whichever + // quorum finished last. + if (isActionPending.value) return + isActionPending.value = true + + val template = GroupNostrProfile.template( + latest = groupNostrProfile, + name = nameField.text.toString().trim(), + about = aboutField.text.toString().trim(), + picture = pictureField.text.toString().trim(), + website = websiteField.text.toString().trim(), + nip05 = nip05Field.text.toString().trim(), + lud16 = lud16Field.text.toString().trim(), + ) + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = template.kind, + tags = template.tags, + content = template.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a nostr profile for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "EditGroupNostrProfileViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupNostrProfileUIState: EditGroupNostrProfileUIState = + EditGroupNostrProfileUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + EditGroupNostrProfileViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupNostrProfileUIState = initialEditGroupNostrProfileUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt new file mode 100644 index 00000000..fbae1c94 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModel.kt @@ -0,0 +1,299 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupRelaysUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * The group's relay lists, edited locally and proposed together. + * + * Nothing here is saved. The working copy lives in [working] until the button is + * pressed, and what the button does is open one signing session over the sets that + * changed -- so the group's relay lists change on every member's device at once, + * when a quorum has signed, or not at all. + * + * ### Why one session rather than one per set + * + * Wisp publishes a tab at a time because a person's client signs for itself and + * there is nothing to coordinate. Here every signature costs a quorum's attention, + * and four separate sessions for one sitting at one screen would ask for it four + * times over what is plainly a single decision: where this group lives. + * + * The batch carries **only the sets that differ**, which is what keeps that honest. + * Re-signing a list nobody touched would put a second, identical statement on the + * record with a newer timestamp -- harmless, since these are replaceable, and still + * a lie about when the group last decided anything. + * + * `FrostSigningManager` documents the cost of batching: a batch is all-or-nothing + * and so only as available as its worst item. These four items are the same size, + * the same shape and signed by the same quorum in the same second, so there is no + * worst one to be dragged down by. + */ +class EditGroupRelaysViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialEditGroupRelaysUIState: EditGroupRelaysUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var editGroupRelaysUIState: EditGroupRelaysUIState by mutableStateOf( + initialEditGroupRelaysUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * The working copy: what the lists would be if the group agreed. + * + * A snapshot map so the editor recomposes as rows are added and removed, and + * held on the view model rather than in the composition so that switching tabs + * -- which is a pager page changing -- does not throw away an edit. + */ + val working = mutableStateMapOf>() + + /** Which set the editor is showing, and so what the add field adds to. */ + var selectedSet: GroupRelaySet by mutableStateOf(GroupRelaySet.entries.first()) + private set + + /** + * Why the last add did not happen, or null. Cleared by the next keystroke. + * + * Named rather than silent: Wisp's add button does nothing at all for a URL it + * refuses, and "nothing happened" is indistinguishable from a missed tap. + */ + var addFailure: AddFailure? by mutableStateOf(null) + private set + + enum class AddFailure { + /** Not a `wss://` URL, or one pointing at this machine. */ + NotARelay, + + /** Already in this set, which is a no-op rather than a mistake. */ + AlreadyListed, + } + + fun initiateEditGroupRelays() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + editGroupRelaysUIState = if (localChatRoom == null) { + EditGroupRelaysUIState.Error("Couldn't find the chat room") + } else { + EditGroupRelaysUIState.Loaded( + localChatRoom = localChatRoom, + signed = chatRepository.groupRelayLists(chatRoomId), + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Fills the working copy from what the group signed, leaving edits alone. + * + * A set the group has agreed starts at its list; one it has not starts at the + * set's defaults, which is what puts this build's own relay in front of a group + * setting up for the first time rather than an empty list. See + * `GroupRelaySet.defaults`. + * + * Called from the screen rather than from [initiateEditGroupRelays], because + * the loader is not the only way a loaded state arrives -- a preview and a + * layout test both hand one straight in, and seeding only on the load path gave + * both of them an editor with nothing in it while the app worked fine. The + * screen has the state either way. + * + * **Only fills what is missing**, so running again after the member has typed + * something does not undo it. That matters because the effect that calls this + * is keyed on a state the view model can re-emit. + */ + fun seedWorkingCopy(signed: List) { + signed.forEach { list -> + if (list.set in working) return@forEach + + working[list.set] = if (list.isAgreed) list.relays else list.set.defaults() + } + } + + fun selectSet(set: GroupRelaySet) { + selectedSet = set + addFailure = null + } + + fun clearAddFailure() { + addFailure = null + } + + fun relaysOf(set: GroupRelaySet): List = working[set] ?: emptyList() + + /** + * Adds [url] to the selected set, or says why it did not. + * + * True when the row appeared, which is the caller's cue to clear the field. + */ + fun addRelay(url: String): Boolean { + val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run { + addFailure = AddFailure.NotARelay + return false + } + + val current = relaysOf(selectedSet) + if (current.any { it.url == relayUrl }) { + addFailure = AddFailure.AlreadyListed + return false + } + + working[selectedSet] = current + GroupRelay(relayUrl) + addFailure = null + return true + } + + fun removeRelay(set: GroupRelaySet, relay: GroupRelay) { + working[set] = relaysOf(set).filterNot { it.url == relay.url } + } + + /** + * Turns a NIP-65 marker on or off, refusing to turn off the last one. + * + * A relay that is neither read nor write has no tag in NIP-65 -- see + * `GroupRelaySet.template` -- so the state does not exist to be put the group's + * signature on. What it would mean is that the relay is not in the list, and + * removing it is the row's own button. + */ + fun toggleRead(set: GroupRelaySet, relay: GroupRelay) = + toggle(set, relay) { it.copy(read = !it.read) } + + fun toggleWrite(set: GroupRelaySet, relay: GroupRelay) = + toggle(set, relay) { it.copy(write = !it.write) } + + private fun toggle( + set: GroupRelaySet, + relay: GroupRelay, + change: (GroupRelay) -> GroupRelay, + ) { + working[set] = relaysOf(set).map { + if (it.url != relay.url) return@map it + + change(it).takeIf { changed -> changed.read || changed.write } ?: it + } + } + + /** + * The sets whose working copy says something different to what the group + * signed, in the order the editor shows them. + * + * Order-sensitive on purpose. A relay list is written in the order it is read + * back, and a member who moved a relay to the front meant to; comparing as sets + * would call that no change and quietly refuse to propose it. + * + * A set the group has never agreed counts as changed as soon as it holds + * anything, so a first-time group's seeded defaults are a real proposal rather + * than an accident, and an empty one it has never agreed counts as unchanged -- + * there is nothing to say. + */ + fun changedSets(signed: List): List = + signed.filter { list -> relaysOf(list.set) != list.relays } + .filterNot { list -> !list.isAgreed && relaysOf(list.set).isEmpty() } + .map { it.set } + + /** + * Opens one session over every set that changed. + * + * [onNothingToPropose] rather than a session over nothing: + * `proposeSigningBatch` refuses an empty batch outright, and a member who has + * changed nothing is asking a question rather than making a mistake. + */ + fun proposeRelayLists( + localChatRoom: LocalChatRoom, + signed: List, + onSuccess: (sessionId: String) -> Unit, + onNothingToPropose: () -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. Two sessions over two versions of one relay + // list would leave the group's answer decided by whichever quorum finished + // last. + if (isActionPending.value) return + + val changed = changedSets(signed) + if (changed.isEmpty()) { + onNothingToPropose.invoke() + return + } + + isActionPending.value = true + + val templates = changed.map { set -> set.template(relaysOf(set)) } + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigningBatch( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + events = templates, + ) + }.onFailure { error -> + logger.e("Failed to propose relay lists for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "EditGroupRelaysViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialEditGroupRelaysUIState: EditGroupRelaysUIState = + EditGroupRelaysUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + EditGroupRelaysViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialEditGroupRelaysUIState = initialEditGroupRelaysUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 80f08814..394336cd 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -7,6 +7,8 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelayList sealed interface ChatRoomDetailUIState { data class Loaded( @@ -50,6 +52,41 @@ sealed interface ChatRoomDetailUIState { * really is a subgroup. */ val parentChatRoomId: HexKey? = null, + /** + * The kind:0 the group signed about itself, or null while it has signed + * none. + * + * Sits under the room's identity and above its work, because that is what + * it is: the name and the picture the rest of nostr sees on the key the + * signing key row names. Null is an ordinary state and the screen says so + * -- a group has an identity from the moment it has a key and describes it + * whenever somebody gets round to it. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Where the group has said it lives on nostr, one entry per relay set. + * + * Beside the profile because they are the same kind of fact -- the group's + * own account of itself, signed by the group -- and read in the same breath + * by anybody checking whether the group is reachable. + * + * Empty only in a room that has none of this to say. Every other case has + * four entries, some of them unsigned; see `ChatRepository.groupRelayLists`. + */ + val groupRelayLists: List = emptyList(), + /** + * Whether this device could sign a profile for the group. + * + * The same capability [canAddSubgroup] wants, and wanted for the same + * reason: writing the group's profile is a signature by the group, and + * `FrostSigningManager.proposeSigningBatch` throws for a device holding no + * share of the key. It is not about permission -- any member with a share + * may propose -- and the quorum is what decides. + * + * A NIP-17 room is excluded by the same condition, since it has no key of + * its own to sign as and so no nostr identity to describe. + */ + val canEditNostrProfile: Boolean = false, /** * Whether this device could open a subgroup here at all. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt new file mode 100644 index 00000000..77f7ee64 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupNostrProfileUIState.kt @@ -0,0 +1,34 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile + +sealed interface EditGroupNostrProfileUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * What the group has already said about itself, or null the first time. + * + * The form starts from it, and so does the event: an edit is built with + * `MetadataEvent.updateFromPast` so a field this app does not offer survives + * being edited rather than being dropped by it. See + * [GroupNostrProfile.template]. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the form readable and the button inert, which is the honest + * shape: the profile is worth reading either way, and a member without a + * share cannot open a session for one. See + * `ChatRoomDetailUIState.canEditNostrProfile`. + */ + val canSign: Boolean = false, + ): EditGroupNostrProfileUIState + + data class Error( + val message: String + ): EditGroupNostrProfileUIState + + data object Loading: EditGroupNostrProfileUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt new file mode 100644 index 00000000..08f7963e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupRelaysUIState.kt @@ -0,0 +1,33 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupRelayList + +sealed interface EditGroupRelaysUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * What the group has signed for each set, which is what an edit is measured + * against. + * + * Kept beside the working copy rather than replaced by it, because the + * button only proposes the sets that actually differ -- and "differ" needs + * both halves. See `EditGroupRelaysViewModel.changedSets`. + */ + val signed: List = emptyList(), + /** + * Whether this device holds a share of the group's key. + * + * False leaves the lists readable and the button inert: where the group + * says it lives is worth reading whoever is looking, and proposing a change + * to it is a signature only a share-holder can start. + */ + val canSign: Boolean = false, + ): EditGroupRelaysUIState + + data class Error( + val message: String + ): EditGroupRelaysUIState + + data object Loading: EditGroupRelaysUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt new file mode 100644 index 00000000..944e7199 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupNostrProfileTest.kt @@ -0,0 +1,438 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * What a group's nostr profile is allowed to be read out of. + * + * A room's id is the pubkey it signs as, so a kind:0 authored by that key is the + * group describing itself and a kind:0 authored by anything else is not -- however + * it came to be filed against the room. The reading is what the group detail screen + * shows and what an edit is built on top of, so both halves are worth pinning: + * which events become a profile, and what an edit does to the one before it. + * + * The edit half is the one with a silent failure behind it. `updateFromPast` is + * used precisely so a field this app does not offer survives being edited, and + * `createNew` in its place would compile, pass any test that only looked at the + * fields the form knows about, and quietly wipe the rest of the group's profile + * every time somebody fixed a typo in its name. + */ +class GroupNostrProfileTest { + private val participants = 3 + private val threshold = 2 + + /** The group whose profile this is. */ + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the profiles signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + private val strangerChatRoomId = + SharedKeyDerivation.marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path) + + @Test + fun `a kind zero the room signed is the group's profile`() { + val profile = GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"Translation collective","about":"We translate hard books.",""" + + """"picture":"https://example.com/group.png","nip05":"group@example.com"}""" + ), + chatRoomId = chatRoomId + ) + + assertNotNull(profile) + assertEquals("Translation collective", profile.name()) + assertEquals("We translate hard books.", profile.about()) + assertEquals("https://example.com/group.png", profile.picture()) + assertEquals("group@example.com", profile.nip05()) + assertEquals(chatRoomId, profile.publicKey) + } + + @Test + fun `display_name is what a name is read out of when both are set`() { + // Every other nostr client prefers it, and `template` writes both so they + // agree. A group whose two names disagree got them from somewhere else, + // and the answer has to be the one the rest of nostr will give. + val profile = GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"collective","display_name":"Translation collective"}""" + ), + chatRoomId = chatRoomId + ) + + assertEquals("Translation collective", profile?.name()) + } + + @Test + fun `a kind zero another group signed is not this group's profile`() { + // A real quorum and a real signature by a group with no standing to say + // anything about this room. The row names this room because that is where + // the event was filed; the author is what decides whose profile it is. + val stranger = GroupSignedEvent.fromEvent( + event = metadataEvent( + content = """{"name":"Somebody else"}""", + material = strangerMaterial + ), + chatRoomId = chatRoomId + ) + + assertEquals(strangerChatRoomId, stranger.publicKey, "the fixture signs as the stranger") + assertNull(GroupNostrProfile.of(signedEvent = stranger, chatRoomId = chatRoomId)) + } + + @Test + fun `a kind zero whose signature is not the room's is not a profile`() { + // Authored by the room, saying the right things, and signed by a key that + // is not the room's. This is the shape a member forging a rename produces. + assertNull( + GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"Renamed by one member"}""", + signer = strangerMaterial + ), + chatRoomId = chatRoomId + ) + ) + + // And everything off the wire is allowed to be nonsense, which means no + // rather than an exception. + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupNostrProfile.of( + signedEvent = signed(content = """{"name":"x"}""", signature = rubbish), + chatRoomId = chatRoomId + ), + "a profile signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `an event of another kind is not a profile`() { + assertNull( + GroupNostrProfile.of( + signedEvent = signed(content = """{"name":"x"}""").copy(kind = 1), + chatRoomId = chatRoomId + ) + ) + } + + @Test + fun `the newest profile the group signed is the one that counts`() { + // kind:0 is replaceable: editing a profile signs a second one, and the + // second is the answer whatever order the query hands them over in. + val older = signed(content = """{"name":"What it was called"}""", createdAt = 1_700_000_000) + val newer = signed(content = """{"name":"What it is called"}""", createdAt = 1_700_000_900) + + listOf(listOf(older, newer), listOf(newer, older)).forEach { events -> + assertEquals( + "What it is called", + GroupNostrProfile.newestAmong(events, chatRoomId)?.name(), + "the newest profile lost to query order" + ) + } + } + + @Test + fun `two profiles signed in the same second resolve the same way on every device`() { + // Two devices catching up read the same events in whatever order the + // relay or the query gives them, and they have to end up showing the same + // profile. A tie on the timestamp is broken by the id, which every device + // agrees on because it is a hash of the event. + val one = signed(content = """{"name":"One"}""", createdAt = 1_700_000_000) + val other = signed(content = """{"name":"Other"}""", createdAt = 1_700_000_000) + + val expected = GroupNostrProfile.newestAmong(listOf(one, other), chatRoomId)?.name() + + assertNotNull(expected) + assertEquals( + expected, + GroupNostrProfile.newestAmong(listOf(other, one), chatRoomId)?.name(), + "a tie on the timestamp was broken differently by the two orderings" + ) + assertEquals( + if (one.id > other.id) "One" else "Other", + expected, + "the tie should break on the id, which is the only thing both devices share" + ) + } + + @Test + fun `the first profile a group signs carries the fields it was given`() { + val template = GroupNostrProfile.template( + latest = null, + name = "Translation collective", + about = "We translate hard books.", + picture = "https://example.com/group.png", + website = "https://example.com", + nip05 = "group@example.com", + lud16 = "group@getalby.com" + ) + + val profile = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + ) + + assertNotNull(profile) + assertEquals(MetadataEvent.KIND, template.kind) + assertEquals("Translation collective", profile.name()) + assertEquals("We translate hard books.", profile.about()) + assertEquals("https://example.com/group.png", profile.picture()) + assertEquals("https://example.com", profile.website()) + assertEquals("group@example.com", profile.nip05()) + assertEquals("group@getalby.com", profile.lud16()) + } + + @Test + fun `an edit keeps a field the form does not offer`() { + // The reason `template` builds on the group's own event rather than from + // scratch. `bot` is not on the form and never will be reached by it; an + // edit that dropped it would be this app deciding a group is not a bot + // because somebody fixed its name. + val existing = GroupNostrProfile.of( + signedEvent = signed(content = """{"name":"Old name","bot":true}"""), + chatRoomId = chatRoomId + ) + assertNotNull(existing) + + val template = GroupNostrProfile.template( + latest = existing, + name = "New name", + about = "", + picture = "", + website = "", + nip05 = "", + lud16 = "" + ) + + val edited = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + ) + + assertNotNull(edited) + assertEquals("New name", edited.name()) + assertEquals(true, edited.metadata.bot, "an edit dropped a field the form does not offer") + } + + @Test + fun `an emptied field unsays what the group had said`() { + // The only way to withdraw something a group has signed. A form that sent + // nulls for its blanks could add to a profile and never subtract from one. + val existing = GroupNostrProfile.of( + signedEvent = signed( + content = """{"name":"Translation collective","about":"Something regretted"}""" + ), + chatRoomId = chatRoomId + ) + assertNotNull(existing) + + val template = GroupNostrProfile.template( + latest = existing, + name = "Translation collective", + about = "", + picture = "", + website = "", + nip05 = "", + lud16 = "" + ) + + val edited = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + ) + + assertNotNull(edited) + assertNull(edited.about()) + assertEquals("Translation collective", edited.name()) + } + + @Test + fun `a name is written to both of the keys a reader might look in`() { + val template = GroupNostrProfile.template( + latest = null, + name = "Translation collective", + about = "", + picture = "", + website = "", + nip05 = "", + lud16 = "" + ) + + val metadata = GroupNostrProfile.of( + signedEvent = signed(content = template.content), + chatRoomId = chatRoomId + )?.metadata + + assertNotNull(metadata) + assertEquals("Translation collective", metadata.name) + assertEquals( + "Translation collective", + metadata.displayName, + "a client preferring display_name would show the old name after an edit" + ) + } + + @Test + fun `an empty profile is a profile rather than a parse failure`() { + // Wiping a profile is something a group is entitled to do, and an empty + // kind:0 is how nostr says it. Reading it as nothing would leave the + // screen claiming the group had never said anything. + val profile = GroupNostrProfile.of( + signedEvent = signed(content = ""), + chatRoomId = chatRoomId + ) + + assertNotNull(profile) + assertNull(profile.name()) + assertNull(profile.about()) + } + + @Test + fun `content that is not a profile at all is refused`() { + assertNull( + GroupNostrProfile.of( + signedEvent = signed(content = "not json"), + chatRoomId = chatRoomId + ) + ) + } + + /** + * A profile row as `FrostSigningManager.complete` files one: the content under + * the room's own key, with the id hashed over it and the group's signature on + * it. + * + * [signer] is pulled apart from the author so a signature by the wrong quorum + * can be tested, and [signature] replaces the real one outright for what is + * not a signature at all. + */ + private fun signed( + content: String, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = metadataEvent( + content = content, + createdAt = createdAt, + material = material, + signer = signer, + signature = signature + ), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun metadataEvent( + content: String, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + * + * Assembled any other way it would not be evidence about the signatures this + * app actually produces. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt new file mode 100644 index 00000000..e58088ab --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupRelayListTest.kt @@ -0,0 +1,398 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a group's relay lists are allowed to be read out of, and what an edit + * produces. + * + * The reading half is `GroupNostrProfileTest`'s again -- the room has to be the + * author, the signature has to be the room's -- and it is repeated here because + * these are four different kinds through two different tag vocabularies, and a + * mistake in any one of them would be invisible in the other three. + * + * The writing half is the one that could not be settled by reading the code. + * NIP-65's markers are absent for both, `read` for read-only and `write` for + * write-only, so the tag for the ordinary case is the one with the least in it -- + * exactly the shape a round trip is most likely to lose. And "neither" is a state + * the format cannot express at all, which is why the editor refuses it and why + * building one drops it rather than writing a tag that means the opposite. + */ +class GroupRelayListTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the lists signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + + @Test + fun `every set round trips through the tags it is written in`() { + // Four kinds and two tag vocabularies: NIP-65's "r" for General and the + // "relay" tag for the other three, through two RelayTag classes that are + // different classes in different packages. A set wired to the wrong one + // writes tags nothing reads back. + GroupRelaySet.entries.forEach { set -> + val relays = listOf(GroupRelay(one), GroupRelay(two)) + val list = GroupRelayList.of( + signedEvent = signed(set.template(relays)), + chatRoomId = chatRoomId, + set = set + ) + + assertEquals( + listOf(one, two), + list?.relays?.map { it.url }, + "${set.name} did not round trip through its own tags" + ) + } + } + + @Test + fun `a relay list only reads back as the set it was written as`() { + // The kinds are the only thing separating a general list from a blocked + // one, and reading a blocked list as a general one would have the group + // publishing to the relays it refuses to talk to. + val blocked = signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(one)))) + + assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.General)) + assertNull(GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Search)) + assertEquals( + listOf(one), + GroupRelayList.of(blocked, chatRoomId, GroupRelaySet.Blocked)?.relays?.map { it.url } + ) + } + + @Test + fun `read and write markers survive being written and read`() { + // The ordinary case is the dangerous one: NIP-65 writes both by *omitting* + // the marker, so a round trip that lost the third element entirely would + // still look correct for a both-ways relay and would silently turn a + // read-only relay into a write one. + val relays = listOf( + GroupRelay(one, read = true, write = true), + GroupRelay(two, read = true, write = false), + GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example"), + read = false, write = true), + ) + + val readBack = GroupRelayList.of( + signedEvent = signed(GroupRelaySet.General.template(relays)), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ) + + assertEquals(relays, readBack?.relays) + } + + @Test + fun `a relay that is neither read nor write is not written at all`() { + // NIP-65 has no marker for it. The editor will not produce the state, and + // this is the second line of that defence: writing a bare `r` tag would + // advertise it as both, which is the opposite of what was asked. + val list = GroupRelayList.of( + signedEvent = signed( + GroupRelaySet.General.template( + listOf( + GroupRelay(one, read = false, write = false), + GroupRelay(two), + ) + ) + ), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ) + + assertEquals(listOf(two), list?.relays?.map { it.url }) + } + + @Test + fun `a relay list another group signed is not this group's`() { + val stranger = GroupSignedEvent.fromEvent( + event = relayEvent( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + material = strangerMaterial + ), + chatRoomId = chatRoomId + ) + + assertNull(GroupRelayList.of(stranger, chatRoomId, GroupRelaySet.General)) + } + + @Test + fun `a relay list the room did not sign is not a relay list`() { + // Authored by the room and signed by somebody else -- the shape a member + // redirecting a group's traffic would produce. + assertNull( + GroupRelayList.of( + signedEvent = signed( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + signer = strangerMaterial + ), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ) + ) + + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupRelayList.of( + signedEvent = signed( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + signature = rubbish + ), + chatRoomId = chatRoomId, + set = GroupRelaySet.General + ), + "a list signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `a set the group never agreed reads back unsigned and empty`() { + // Not null. The screen has a row per set whatever the group has said, and + // `isAgreed` is what separates "never said" from "said nothing". + val list = GroupRelayList.newestAmong(emptyList(), chatRoomId, GroupRelaySet.Search) + + assertFalse(list.isAgreed) + assertEquals(emptyList(), list.relays) + assertEquals(GroupRelaySet.Search, list.set) + assertNull(list.signedAt) + } + + @Test + fun `an agreed empty list is not the same as one never agreed`() { + // A group withdrawing its relay list signs an empty one, and that decision + // has to survive being read back -- otherwise a deliberate withdrawal looks + // exactly like never having got round to it. + val list = GroupRelayList.of( + signedEvent = signed(GroupRelaySet.Messages.template(emptyList())), + chatRoomId = chatRoomId, + set = GroupRelaySet.Messages + ) + + assertTrue(list?.isAgreed == true) + assertEquals(emptyList(), list?.relays) + } + + @Test + fun `the newest list the group signed is the one that counts`() { + val older = signed( + GroupRelaySet.General.template(listOf(GroupRelay(one))), + createdAt = 1_700_000_000 + ) + val newer = signed( + GroupRelaySet.General.template(listOf(GroupRelay(two))), + createdAt = 1_700_000_900 + ) + + listOf(listOf(older, newer), listOf(newer, older)).forEach { events -> + assertEquals( + listOf(two), + GroupRelayList.newestAmong(events, chatRoomId, GroupRelaySet.General) + .relays.map { it.url }, + "the newest relay list lost to query order" + ) + } + } + + @Test + fun `allAmong sorts every set into one entry each`() { + val events = listOf( + signed(GroupRelaySet.General.template(listOf(GroupRelay(one)))), + signed(GroupRelaySet.Blocked.template(listOf(GroupRelay(two)))), + ) + + val all = GroupRelayList.allAmong(events, chatRoomId) + + assertEquals(GroupRelaySet.entries.toList(), all.map { it.set }) + assertEquals(listOf(one), all.first { it.set == GroupRelaySet.General }.relays.map { it.url }) + assertEquals(listOf(two), all.first { it.set == GroupRelaySet.Blocked }.relays.map { it.url }) + assertFalse(all.first { it.set == GroupRelaySet.Search }.isAgreed) + assertFalse(all.first { it.set == GroupRelaySet.Messages }.isAgreed) + } + + @Test + fun `every set but blocked starts at the app's own relay`() { + // What a group setting up for the first time is shown. Seeding with nothing + // would make its first proposal an empty list, which says less than never + // having said anything. + GroupRelaySet.entries.filterNot { it == GroupRelaySet.Blocked }.forEach { set -> + assertEquals( + listOf(Relays.ephemeral), + set.defaults().map { it.url }, + "${set.name} did not default to the app's relay" + ) + } + + // And blocked starts empty: a default there is a refusal to talk to + // somebody that nobody in the group chose. + assertEquals(emptyList(), GroupRelaySet.Blocked.defaults()) + } + + @Test + fun `only a wss relay somewhere other than this device is accepted`() { + assertEquals(one, GroupRelaySet.relayUrlOrNull("wss://relay.one.example")) + assertEquals(one, GroupRelaySet.relayUrlOrNull(" wss://relay.one.example ")) + + // A group's list is read by strangers over the open network: ws:// asks + // them to fetch it in the clear, and loopback names a relay only one device + // can reach. + listOf( + "ws://relay.one.example", + "http://relay.one.example", + "relay.one.example", + "wss://localhost", + "wss://127.0.0.1", + "", + ).forEach { + assertNull(GroupRelaySet.relayUrlOrNull(it), "\"$it\" was accepted as a relay") + } + } + + @Test + fun `a duplicated relay is read once`() { + // Two tags for one relay is one relay said twice, and a list that shows it + // twice reads as two to a human -- the same reasoning a birth certificate's + // admin roster is deduplicated on. + val duplicated = Event( + id = "", + pubKey = chatRoomId, + createdAt = 1_700_000_000, + kind = AdvertisedRelayListEvent.KIND, + tags = arrayOf(arrayOf("r", one.url), arrayOf("r", one.url, "read")), + content = "", + sig = "" + ) + + assertEquals(listOf(one), GroupRelaySet.General.parse(duplicated.tags).map { it.url }) + } + + /** A relay list as `FrostSigningManager.complete` files one. */ + private fun signed( + template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = relayEvent(template, createdAt, material, signer, signature), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun relayEvent( + template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<*>, + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt new file mode 100644 index 00000000..1d0d4eb4 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -0,0 +1,278 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ChatRoomDetailUIState +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Where the group's nostr profile sits, and when it is there at all. + * + * The section's contents need no test -- a name and a line of prose in a + * `ListItem` -- but two things about it do, and neither is settled by reading the + * screen top to bottom. + * + * **Its place in the order.** It goes under what the room *is* -- the key it signs + * as and whose child it is -- and above what the room has *made*, because a + * profile is part of the identity rather than part of the work. That ordering is + * an intention that a later `item {}` inserted in the wrong place would silently + * undo, and the screen is long enough that nobody would notice. + * + * **Its absence in a NIP-17 room.** A NIP-17 room's id is a conversation, not a + * key it can sign as, so it has no nostr identity and never will. An empty section + * there would promise something that is not coming, which is worse than saying + * nothing. + */ +@OptIn(ExperimentalTestApi::class) +class GroupNostrProfileSectionJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val profile = GroupNostrProfile( + signedEvent = GroupSignedEvent( + id = "e".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + // Built rather than read out of the event: the reading checks a real + // signature, and this test is about the screen rather than about that. + metadata = UserMetadata().apply { + displayName = "Translation collective" + about = "A group translating hard books into Sesotho." + nip05 = "group@example.com" + } + ) + + @Test + fun `the profile sits under the signing key and above the library`() = render( + state(groupNostrProfile = profile) + ) { + val signingKey = onNodeWithText("Signing key").getBoundsInRoot().top + val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top + val library = onNodeWithText("Library").getBoundsInRoot().top + + assertTrue( + signingKey < nostrProfile, + "the nostr profile section climbed above the signing key row" + ) + assertTrue( + nostrProfile < library, + "the nostr profile section fell below the library" + ) + + onNodeWithText("Translation collective").assertIsDisplayed() + onNodeWithText("group@example.com").assertIsDisplayed() + } + + @Test + fun `the relay lists sit under the profile and above the library`() = render( + state(groupNostrProfile = profile, relayLists = signedRelayLists()) + ) { + val nostrProfile = onNodeWithText("Nostr profile").getBoundsInRoot().top + val relays = onNodeWithText("Relays").getBoundsInRoot().top + val library = onNodeWithText("Library").getBoundsInRoot().top + + assertTrue(nostrProfile < relays, "the relay lists climbed above the profile") + assertTrue(relays < library, "the relay lists fell below the library") + + // Every set gets a row whatever the group has said, and the two absences + // read differently: one has never been agreed, the other was agreed empty. + onNodeWithText("General").assertIsDisplayed() + onNodeWithText("relay.one.example").assertIsDisplayed() + onNodeWithText("Messages").assertIsDisplayed() + onNodeWithText("Search").assertIsDisplayed() + onNodeWithText("Blocked").assertIsDisplayed() + // Two of them, because Messages and Search are both unagreed -- and the + // count is the assertion: a row per set, not a row per set the group has + // got round to. + onAllNodesWithText("Not set yet").assertCountEquals(2) + onNodeWithText("No relays in this list").assertIsDisplayed() + onNodeWithText("Edit relays").assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key reads the relays and is offered no edit`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + canEditNostrProfile = false + ) + ) { + onNodeWithText("General").assertIsDisplayed() + onNodeWithText("relay.one.example").assertIsDisplayed() + onNodeWithText("Edit relays").assertDoesNotExist() + } + + @Test + fun `a group that has said nothing says so, and offers to say something`() = render( + state(groupNostrProfile = null) + ) { + onNodeWithText("Nostr profile").assertIsDisplayed() + onNodeWithText("This group hasn't said anything about itself on Nostr yet.") + .assertIsDisplayed() + onNodeWithText("Edit Nostr profile").assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key reads the profile and is offered no edit`() = render( + state(groupNostrProfile = profile, canEditNostrProfile = false) + ) { + // The profile is worth reading whoever is looking; proposing one needs a + // share, and `proposeSigningBatch` throws without one. + onNodeWithText("Translation collective").assertIsDisplayed() + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + } + + @Test + fun `a NIP-17 room has no nostr identity and so no section`() = render( + state(groupNostrProfile = null, mlsGroupState = null, canEditNostrProfile = false) + ) { + onNodeWithText("Nostr profile").assertDoesNotExist() + onNodeWithText("This group hasn't said anything about itself on Nostr yet.") + .assertDoesNotExist() + onNodeWithText("Edit Nostr profile").assertDoesNotExist() + + // The relay lists go with it: they describe a nostr identity this room + // does not have. + onNodeWithText("Relays").assertDoesNotExist() + onNodeWithText("Edit relays").assertDoesNotExist() + + // The rest of the screen is untouched, so the section's absence is an + // absence rather than a screen that failed to draw. + onNodeWithText("Library").assertIsDisplayed() + } + + /** + * Two agreed sets and two absences, which is the only fixture that exercises + * the three things a row can say: a list, "not set yet", and "no relays". + */ + private fun signedRelayLists(): List = GroupRelaySet.entries.map { set -> + when (set) { + GroupRelaySet.General -> GroupRelayList( + set = set, + signedEvent = relaySignedEvent(set), + relays = listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.one.example"))) + ) + // Agreed and empty: the group withdrew its list rather than never + // having had one. + GroupRelaySet.Blocked -> GroupRelayList( + set = set, + signedEvent = relaySignedEvent(set), + relays = emptyList() + ) + else -> GroupRelayList(set = set, signedEvent = null, relays = emptyList()) + } + } + + private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent( + id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + private fun state( + groupNostrProfile: GroupNostrProfile?, + relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), + canEditNostrProfile: Boolean = true, + mlsGroupState: String? = "state" + ) = ChatRoomDetailUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + // Deliberately not the profile's name. The room's subject is this + // device's name for it and the profile is the group's own, and a + // test that used one string for both could not tell them apart. + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = mlsGroupState + ) + ), + // A key state, so the signing key row the section has to sit under is on + // the screen to be measured against. Not a real key: nothing here derives. + groupKeyState = GroupKeyState( + chatRoomId = chatRoomId, + dkgSessionId = "c".repeat(64), + thresholdPublicKey = "02".padEnd(66, 'a'), + derivationPath = "m/9420/0/0", + announcedBy = chatRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000) + ), + groupNostrProfile = groupNostrProfile, + groupRelayLists = relayLists, + canEditNostrProfile = canEditNostrProfile + ) + + /** + * The screen at a phone's width and a window tall enough to compose the whole + * of it, since a `LazyColumn` does not lay out what it would not show. + * + * The state is passed in rather than loaded, so the NO_OP repositories are + * never asked for anything: `initiateChatRoomDetail` only runs for a screen + * that arrives in [ChatRoomDetailUIState.Loading]. + */ + private fun render( + uiState: ChatRoomDetailUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 4000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + ChatRoomDetailScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialChatRoomDetailUIState = uiState, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY, + onNavigateToRoute = {}, + onPopBackToRoute = {} + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt new file mode 100644 index 00000000..91ac1332 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupRelaysViewModelJvmTest.kt @@ -0,0 +1,224 @@ +package press.mantra.compose.ui.view.model + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.Relays +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.EditGroupRelaysUIState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The two decisions the relay editor makes that nothing else can check. + * + * **What counts as a change.** The button proposes only the lists that differ, so + * this is what decides whether a quorum is asked at all -- and getting it wrong in + * either direction is quiet. Too eager and every visit re-signs four lists nobody + * touched, dating a decision the group did not make; too shy and an edit is + * silently dropped on a screen that said it had been sent. + * + * **What a NIP-65 marker is allowed to become.** A relay that is neither read nor + * write has no tag in the format, so the state must not be reachable. + * `GroupRelaySet.template` drops one as a last resort; this is where it is actually + * prevented. + */ +class EditGroupRelaysViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + + private fun viewModel() = EditGroupRelaysViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = "a".repeat(64), + relayHint = null, + initialEditGroupRelaysUIState = EditGroupRelaysUIState.Loading, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + ) + + private fun signedEvent(set: GroupRelaySet) = GroupSignedEvent( + id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = set.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + /** Every set unsigned, which is a group opening the editor for the first time. */ + private fun nothingSigned() = GroupRelayList.allAmong(emptyList(), chatRoomId) + + private fun signed(set: GroupRelaySet, relays: List) = + nothingSigned().map { + if (it.set == set) GroupRelayList(set, signedEvent(set), relays) else it + } + + @Test + fun `a first-time group is seeded with the app's own relay and that is a change`() { + val signed = nothingSigned() + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + assertEquals( + listOf(Relays.ephemeral), + viewModel.relaysOf(GroupRelaySet.General).map { it.url }, + ) + + // Seeded and therefore proposable. A group whose editor filled itself in + // and then refused to send it would be showing a plan it will not carry out. + assertEquals( + listOf(GroupRelaySet.General, GroupRelaySet.Messages, GroupRelaySet.Search), + viewModel.changedSets(signed), + ) + } + + @Test + fun `blocked is never seeded and so is never proposed by simply opening the screen`() { + val signed = nothingSigned() + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + assertEquals(emptyList(), viewModel.relaysOf(GroupRelaySet.Blocked)) + assertFalse(GroupRelaySet.Blocked in viewModel.changedSets(signed)) + } + + @Test + fun `opening a group that has agreed everything proposes nothing`() { + // The case that has to be silent. A member opening the editor, reading it + // and pressing the button must not spend a quorum on four identical lists. + val signed = GroupRelaySet.entries.map { set -> + GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) + } + + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + assertEquals(emptyList(), viewModel.changedSets(signed)) + } + + @Test + fun `only the set that was edited is proposed`() { + val signed = GroupRelaySet.entries.map { set -> + GroupRelayList(set, signedEvent(set), listOf(GroupRelay(one))) + } + + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + viewModel.selectSet(GroupRelaySet.Search) + assertTrue(viewModel.addRelay(two.url)) + + assertEquals(listOf(GroupRelaySet.Search), viewModel.changedSets(signed)) + } + + @Test + fun `reordering a list is a change`() { + // A relay list is written in the order it is read back, and a member who + // moved a relay to the front meant to. Comparing as sets would call this no + // change and refuse to propose it. + val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one), GroupRelay(two))) + + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + viewModel.selectSet(GroupRelaySet.General) + viewModel.removeRelay(GroupRelaySet.General, GroupRelay(one)) + assertTrue(viewModel.addRelay(one.url)) + + assertEquals( + listOf(two, one), + viewModel.relaysOf(GroupRelaySet.General).map { it.url }, + ) + assertTrue(GroupRelaySet.General in viewModel.changedSets(signed)) + } + + @Test + fun `emptying an agreed list is a change, and emptying an unagreed one is not`() { + // Withdrawing a list the group agreed is a decision worth a signature. + // "Still nothing" is not a decision at all, and proposing an empty list for + // a set nobody ever set would be a signature over silence. + val agreed = signed(GroupRelaySet.Messages, listOf(GroupRelay(one))) + val withdrawing = viewModel() + withdrawing.seedWorkingCopy(agreed) + withdrawing.removeRelay(GroupRelaySet.Messages, GroupRelay(one)) + + assertTrue(GroupRelaySet.Messages in withdrawing.changedSets(agreed)) + + val untouched = nothingSigned() + val quiet = viewModel() + quiet.seedWorkingCopy(untouched) + // Clear the seeded default so the set is unagreed *and* empty. + quiet.removeRelay(GroupRelaySet.Search, GroupRelay(Relays.ephemeral)) + + assertFalse(GroupRelaySet.Search in quiet.changedSets(untouched)) + } + + @Test + fun `a relay cannot be turned into one that is neither read nor write`() { + // NIP-65 has no tag for it, so the state has to be unreachable. What it + // would mean is that the relay is not in the list, and removing it is the + // row's own button. + val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + + viewModel.toggleWrite(GroupRelaySet.General, GroupRelay(one)) + assertEquals( + listOf(GroupRelay(one, read = true, write = false)), + viewModel.relaysOf(GroupRelaySet.General), + ) + + // The second toggle is the one that would leave it as neither. + viewModel.toggleRead( + GroupRelaySet.General, + viewModel.relaysOf(GroupRelaySet.General).first(), + ) + assertEquals( + listOf(GroupRelay(one, read = true, write = false)), + viewModel.relaysOf(GroupRelaySet.General), + "a relay was left neither read nor write, which NIP-65 cannot express", + ) + } + + @Test + fun `a url that is not a relay is refused and named`() { + val viewModel = viewModel() + viewModel.seedWorkingCopy(nothingSigned()) + viewModel.selectSet(GroupRelaySet.Search) + + assertFalse(viewModel.addRelay("http://relay.one.example")) + assertEquals(EditGroupRelaysViewModel.AddFailure.NotARelay, viewModel.addFailure) + + // And a duplicate is a no-op rather than a mistake, said differently. + assertTrue(viewModel.addRelay(one.url)) + assertFalse(viewModel.addRelay(one.url)) + assertEquals(EditGroupRelaysViewModel.AddFailure.AlreadyListed, viewModel.addFailure) + } + + @Test + fun `seeding again leaves an edit alone`() { + // The effect that seeds is keyed on a state the view model can re-emit, so + // running twice has to be harmless -- otherwise a reload halfway through + // typing throws the typing away. + val signed = signed(GroupRelaySet.General, listOf(GroupRelay(one))) + val viewModel = viewModel() + viewModel.seedWorkingCopy(signed) + viewModel.selectSet(GroupRelaySet.General) + assertTrue(viewModel.addRelay(two.url)) + + viewModel.seedWorkingCopy(signed) + + assertEquals( + listOf(one, two), + viewModel.relaysOf(GroupRelaySet.General).map { it.url }, + ) + } +}