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 }, + ) + } +}