feat(groups): the group's own nostr identity, and the two ways a member could forge it

A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`,
where those are one value -- so every group in this app has had a nostr identity from
the moment it had a key, and has never had a way to say anything about it. This adds
the two statements that identity is made of: a kind:0 saying who the group is, and the
four relay lists saying where it can be found. Both are signed by the group's quorum
like everything else it says.

Two sections on the group's detail screen, between the signing key and the library,
and two editors behind them. Neither editor saves: what leaves them is a
`FrostSigningSession`, and the profile changes on every member's device at once when
enough members sign, or not at all.

**A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.**
`Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a
`NostrEvent`: no member sent it, it never travelled the wire as itself, and the
outbound pipeline re-authors rumors as their sender and would strip the group's
signature off -- which is exactly the position `GroupSignedEvent` exists for. So
`GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are
readings rather than rows. Nothing new is stored: `FrostSigningManager.complete`
already files every signed event before it applies one, so both readers had their
data before this change and neither adds a table, a migration or a DAO method beyond
the two existing kind-scoped queries.

**Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust
model.** The author has to be the room, the id has to be the hash of the fields beside
it, and the signature has to verify -- checkable from the row alone, with no ceremony
or key state to consult. A kind:0 that merely arrived in the room is not the group's
profile; a relay list filed against the room by another group is not the group's relay
list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real
FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's
signature, a forged author and three kinds of rubbish in the signature field are all
tested against the code that actually verifies rather than against a mock of it.

**The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft
did not.** Every kind the group signs needs an arm there or it falls through to
`unsupported` and puts raw JSON in the transcript -- so a profile update would have
appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot
tell them apart: a completed signing session, where the author is the room by
construction, and an arriving inner event, where it is whatever the sender wrote. A rumor
carries an empty signature and any pubkey its sender likes, so the relay arm's original
author-only check would have let one member write "the group signed its general relay
list" into the room's transcript with no group involved. It now builds a
`GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it
goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list
passing through the room fails the author half and gets no line at all, which is right --
it is theirs, not the group's.

**`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types.
The profile and the relay lists are the same kind of statement and the transcript does
the same thing with both -- a `RitualNotice`, answered and settled, because by the time
the line exists a quorum has signed and there is nothing left to do about it. A type
missing from that set renders as a chat bubble, silently, looking exactly like a member
having said "The group is now called Translation collective"; that is why it is a set
with two members rather than two checks.

**Relay lists: four sets, and the two that were left out are the interesting part.**
General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked
(NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not
here even though this app writes one for every person: a key package is a device's offer
to be added to an MLS group, and a group has no device and joins nothing, so a group
advertising where its key packages live would point at an address that is permanently
empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason
below.

**A group signs and cannot decrypt, so every list is written in public tags.** NIP-51
puts a relay list's entries in the encrypted half by default, and a blocked list
especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to
the author's own key and there is no ECDH for a FROST threshold key here. That rules out
10012 entirely -- its list is only ever private -- and it means the group's blocked list
is public where a person's client would keep it secret. Said in the code, and said on the
Blocked tab where somebody is about to act on it.

**The editor proposes every list that changed at once, which is the deliberate departure
from the interface it copies.** Wisp publishes the tab in front of you, because a person
signs for themselves and there is nothing to coordinate. Here each signature costs a
quorum's attention, and four sessions for one sitting at one screen would ask for it four
times over what is plainly one decision. `changedSets` is what keeps that honest, and it
is the piece with a quiet failure on both sides: too eager and every visit re-signs four
untouched lists, dating a decision the group did not make; too shy and an edit is dropped
on a screen that said it had been sent. It compares in order, because a relay list is
written in the order it is read back and a member who moved a relay to the front meant to;
it counts a seeded first-time list as a change, so a group whose editor filled itself in
will actually send it; and it counts an empty unagreed set as no change, so opening
Blocked and pressing the button does not put a quorum's signature over silence. Nine
cases in `EditGroupRelaysViewModelJvmTest`.

**A relay that is neither read nor write is a state NIP-65 cannot express**, so it must
not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read`
for read-only and `write` for write-only, and has nothing for neither -- what neither
would mean is that the relay is not in the list, and removing it is the row's own button.
The toggles refuse to turn off the last marker and `template` drops such a relay as a
second line of defence, because a bare tag written for it would advertise it as *both*,
which is the opposite of what was asked. The round trip is tested for all three markers
at once: the ordinary case is the dangerous one, since "both" is encoded by the third
element being *absent*, so a reader that dropped it entirely would look correct for a
both-ways relay and silently promote every read-only one.

**`ephemeral.mantra.press` is what a first-time group is seeded with**, for General,
Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish,
finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to
talk to somebody that nobody in the group chose.

**A profile edit builds on the group's last one; a relay list does not.** `template` for
the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not
offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead
of being dropped by it. Using `createNew` there would compile, pass any test that only
looked at the six fields on the form, and quietly wipe the rest every time somebody fixed
a typo in the group's name. A relay list is the opposite: it is one list, replacing it is
the whole point of signing a new one, and carrying a tag forward would make removal
impossible. Both are tested for the behaviour that would be silent if wrong.

**The name is written to both `name` and `display_name`.** They are the two fields readers
pick a name out of and they disagree at their peril: a group whose `display_name` came
from some other tool and whose `name` was edited here would keep answering to the old one
in every client preferring `display_name` -- an edit that visibly does not take. One field
on the form, both keys in the event.

**Blank clears, and that is the only way to unsay something a group has signed.**
`MetadataEvent`'s rule for these arguments is that an empty string removes the key and
null leaves it alone, so the form sends what its fields hold. Said on the screen, because
a member emptying a field is entitled to know whether it will be ignored.

**Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather
than a key it can sign as, so it has no nostr identity and never will; an empty section
there would promise something that is not coming. Every other room shows the sections and
gates only the two edit buttons on holding a share of the key -- the profile and the relay
lists are worth reading whoever is looking, and only a share-holder can open a session
about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry
points and the subgroup one, since it walks the room's ceremonies looking for a share and
asking three times would do the same walk three times for one answer.

**"Never agreed" and "agreed empty" are different states and the summary says which.**
A set the group has never signed reads "not set yet"; one it signed empty reads "no
relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and
`GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so
the screen has a row per set either way.

**The editor's working copy is seeded from the screen, not from the loader**, and that is
a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path
that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an
empty editor while the app worked fine, which is the shape of bug that survives review
because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy`
is idempotent and fills only missing keys, so the effect that calls it can re-run without
throwing away typing.

**`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since
a group has no `Profile` row to hand it and the picture and the name were all it ever
wanted from one. The two existing overloads delegate to it, so there is one avatar rather
than a second one for groups.

**What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete`
applies signed events locally and puts nothing on the wire, for the reason its comment
gives. So the profile and the lists are visible to members and to nobody else, and the
relay lists say where the group's work *should* go without anything yet sending it there.
Publishing would mean a `NostrEvent` row for an event no member authored, which is an
architectural decision and belongs in its own change. Said at the top of both readers so
the next reader does not assume otherwise.

**And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that
deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds
that a validly signed old one replayed by whoever kept a copy is a statement nobody can
refuse. These five kinds are in the same position, so a member who joins after a profile
is signed will not be handed it in their catch-up. Adding them is a decision with its own
trade and is not made here.

48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape
`\'`, so the aapt spelling would render the backslash.

1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`,
41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets
every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@4c0ed0c1ce
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-13 11:58:00 +02:00
1 parent 2a02fbc14f
commit 590230f215
25 files changed
+4035 -8

No files matched your search

@@ -398,4 +398,52 @@
<string name="check_the_combined_result">Check the combined result and confirm it</string>
<string name="the_group_has_its_key_finish_setting_it_up">The group has its key — finish setting it up</string>
<string name="open">Open</string>
<string name="nostr_profile">Nostr profile</string>
<string name="this_group_has_not_said_anything_about_itself">This group hasn't said anything about itself on Nostr yet.</string>
<string name="edit_nostr_profile">Edit Nostr profile</string>
<string name="propose_nostr_profile">Propose Nostr profile</string>
<string name="what_the_group_says_about_itself_on_nostr">What the group says about itself on Nostr</string>
<string name="the_group_signs_its_profile_so_it_takes_a_quorum">The group signs this, so it takes a quorum. An empty field unsays what the group had said.</string>
<string name="this_group_has_no_shared_key_to_sign_a_profile">This group has no shared key, so it cannot sign a profile. Run a shared key ceremony first.</string>
<string name="signed_by_the_group_on">Signed by the group on %1$s</string>
<string name="no_name_on_this_profile">No name on this profile</string>
<string name="about">About</string>
<string name="picture_url">Picture url</string>
<string name="website">Website</string>
<string name="nostr_address">Nostr address</string>
<string name="lightning_address">Lightning address</string>
<string name="eg_translation_collective">eg. Translation collective</string>
<string name="eg_a_group_translating_hard_books">eg. A group translating hard books into Sesotho</string>
<string name="eg_https_example_com_group_png">eg. https://example.com/group.png</string>
<string name="eg_https_example_com">eg. https://example.com</string>
<string name="eg_group_example_com">eg. group@example.com</string>
<string name="eg_group_getalby_com">eg. group@getalby.com</string>
<string name="could_not_ask_the_group_to_sign_this_profile">Couldn't ask the group to sign this profile.</string>
<string name="relays">Relays</string>
<string name="edit_relays">Edit relays</string>
<string name="not_set_yet">Not set yet</string>
<string name="no_relays_in_this_list">No relays in this list</string>
<string name="where_the_group_lives_on_nostr">Where the group lives on Nostr</string>
<string name="the_group_signs_each_list_so_it_takes_a_quorum">The group signs each list, so it takes a quorum. Only the lists you change are proposed.</string>
<string name="this_group_has_no_shared_key_to_sign_relays">This group has no shared key, so it cannot sign a relay list. Run a shared key ceremony first.</string>
<string name="relay_url">Relay url</string>
<string name="eg_wss_relay_example_com">eg. wss://relay.example.com</string>
<string name="add_relay">Add relay</string>
<string name="that_is_not_a_relay_address">That isn't a relay address. Relays start with wss:// and cannot be on this device.</string>
<string name="that_relay_is_already_in_this_list">That relay is already in this list.</string>
<string name="propose_relays">Propose relays</string>
<string name="nothing_has_changed_to_propose">Nothing has changed, so there is nothing to propose.</string>
<string name="could_not_ask_the_group_to_sign_these_relays">Couldn't ask the group to sign these relays.</string>
<string name="relay_read">Read</string>
<string name="relay_write">Write</string>
<string name="relay_set_general">General</string>
<string name="relay_set_messages">Messages</string>
<string name="relay_set_search">Search</string>
<string name="relay_set_blocked">Blocked</string>
<string name="relay_set_general_purpose">Where the group publishes, and where a reader should look for it.</string>
<string name="relay_set_messages_purpose">Where a message addressed to the group should be sent.</string>
<string name="relay_set_search_purpose">The relays a search of the group's work runs against.</string>
<string name="relay_set_blocked_purpose">Relays the group will not talk to.</string>
<string name="relays_are_public_a_group_cannot_encrypt">A group signs and cannot decrypt, so every list here is public — including the blocked one, which most clients keep private.</string>
<string name="signed_by_the_group_on_short">Signed %1$s</string>
</resources>
@@ -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,
@@ -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
@@ -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<GroupRelayList> = 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) {
@@ -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<GroupSignedEvent>,
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<MetadataEvent> = 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,
)
}
}
@@ -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<GroupRelay>,
) {
/** 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<GroupSignedEvent>,
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<GroupSignedEvent>,
chatRoomId: String,
): List<GroupRelayList> =
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<GroupRelay> = 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<Array<String>>): List<GroupRelay> = 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<GroupRelay>): 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<Nothing>(
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<Kind> = 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() }
}
}
}
@@ -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<GroupRelayList>
/**
* 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> =
GroupRelayList.allAmong(emptyList(), chatRoomId)
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun refuseSubgroup(
@@ -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<GroupRelayList>) {
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,
@@ -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 = {}
)
}
}
}
@@ -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 = {}
)
}
}
}
@@ -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<EditGroupNostrProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditGroupNostrProfileRoute>()
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<EditGroupNostrProfileRoute> {
inclusive = true
}
}
}
)
}
composable<EditGroupRelaysRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditGroupRelaysRoute>()
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<EditGroupRelaysRoute> {
inclusive = true
}
}
}
)
}
composable<FrostSigningRoute> { backStackEntry ->
val route = backStackEntry.toRoute<FrostSigningRoute>()
@@ -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()
@@ -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()
@@ -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
@@ -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,
@@ -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,
)
}
}
@@ -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<Boolean> = 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
)
}
}
}
}
@@ -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<Boolean> = 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<GroupRelaySet, List<GroupRelay>>()
/** 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<GroupRelayList>) {
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<GroupRelay> = 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<GroupRelayList>): List<GroupRelaySet> =
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<GroupRelayList>,
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
)
}
}
}
}
@@ -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<GroupRelayList> = 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.
*
@@ -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
}
@@ -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<GroupRelayList> = 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
}