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